feat(flights): integrate mission-planner into Flights page

- Port mission-planner flight planning to main app (Tailwind, react-leaflet v5, react-i18next)
- Add FlightMap with click-to-add waypoints, draggable markers, polyline with arrows
- Add FlightParamsPanel with action modes, waypoint list (drag-reorder), altitude chart, wind, JSON import/export
- Add FlightListSidebar with create/delete and telemetry date
- Add collapsible left panel with quick action mode shortcuts
- Add work area / no-go zone drawing via manual mouse events (L.rectangle)
- Add AltitudeDialog and JsonEditorDialog (Tailwind modals)
- Add battery/time/distance calculations per waypoint segment
- Add satellite/classic map toggle and mini-map on point drag
- Add Camera FOV and Communication Addr fields
- Add current position display under location search
- Merge mission-planner translations under flights.planner.*
- Gitignore .superpowers session data
This commit is contained in:
Oleksandr Hutsul
2026-04-17 00:31:24 +03:00
parent 567092188d
commit 274800e508
21 changed files with 1489 additions and 131 deletions
+172 -24
View File
@@ -1,35 +1,183 @@
import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet'
import type { Waypoint } from '../../types'
import 'leaflet/dist/leaflet.css'
import { useRef, useEffect, useState } from 'react'
import { MapContainer, TileLayer, Marker, Popup, Polyline, Rectangle, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import 'leaflet-polylinedecorator'
import { useTranslation } from 'react-i18next'
import DrawControl from './DrawControl'
import MapPoint from './MapPoint'
import MiniMap from './MiniMap'
import { defaultIcon } from './mapIcons'
import { TILE_URLS } from './types'
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
const icon = L.divIcon({ className: 'bg-az-orange rounded-full w-3 h-3 border border-white', iconSize: [12, 12] })
interface MapEventsProps {
points: FlightPoint[]
handlePolylineClick: (e: L.LeafletMouseEvent) => void
containerRef: React.RefObject<HTMLDivElement | null>
onMapMove: (center: L.LatLng) => void
}
function MapEvents({ points, handlePolylineClick, containerRef, onMapMove }: MapEventsProps) {
const map = useMap()
const polylineRef = useRef<L.Polyline | null>(null)
const arrowRef = useRef<L.FeatureGroup | null>(null)
useEffect(() => {
const handler = () => onMapMove(map.getCenter())
map.on('moveend', handler)
return () => { map.off('moveend', handler) }
}, [map, onMapMove])
useEffect(() => {
if (polylineRef.current) map.removeLayer(polylineRef.current)
if (arrowRef.current) map.removeLayer(arrowRef.current)
if (points.length > 1) {
const positions: L.LatLngTuple[] = points.map(p => [p.position.lat, p.position.lng])
polylineRef.current = L.polyline(positions, { color: '#228be6', weight: 6, opacity: 0.7, lineJoin: 'round' }).addTo(map)
arrowRef.current = L.polylineDecorator(polylineRef.current, {
patterns: [{ offset: '10%', repeat: '40%', symbol: L.Symbol.arrowHead({ pixelSize: 12, pathOptions: { fillOpacity: 1, weight: 0, color: '#228be6' } }) }],
}).addTo(map)
polylineRef.current.on('click', handlePolylineClick)
}
const observer = new ResizeObserver(() => map.invalidateSize())
if (containerRef.current) observer.observe(containerRef.current)
return () => {
if (polylineRef.current) { map.removeLayer(polylineRef.current); polylineRef.current = null }
if (arrowRef.current) { map.removeLayer(arrowRef.current); arrowRef.current = null }
observer.disconnect()
}
}, [map, points, handlePolylineClick, containerRef])
return null
}
function SetView({ center }: { center: L.LatLngExpression }) {
const map = useMap()
useEffect(() => { map.setView(center) }, [center, map])
return null
}
interface Props {
waypoints: Waypoint[]
points: FlightPoint[]
calculatedPointInfo: CalculatedPointInfo[]
currentPosition: { lat: number; lng: number }
rectangles: MapRectangle[]
setRectangles: React.Dispatch<React.SetStateAction<MapRectangle[]>>
rectangleColor: string
actionMode: ActionMode
onAddPoint: (lat: number, lng: number) => void
onUpdatePoint: (index: number, position: { lat: number; lng: number }) => void
onRemovePoint: (id: string) => void
onAltitudeChange: (index: number, altitude: number) => void
onMetaChange: (index: number, meta: string[]) => void
onPolylineClick: (e: L.LeafletMouseEvent) => void
onPositionChange: (pos: { lat: number; lng: number }) => void
onMapMove: (center: L.LatLng) => void
}
export default function FlightMap({ waypoints }: Props) {
const center: [number, number] = waypoints.length > 0
? [waypoints[0].latitude, waypoints[0].longitude]
: [50.45, 30.52]
export default function FlightMap({
points, currentPosition, rectangles, setRectangles,
rectangleColor, actionMode, onAddPoint, onUpdatePoint, onRemovePoint,
onAltitudeChange, onMetaChange, onPolylineClick, onPositionChange, onMapMove,
}: Props) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const [mapType, setMapType] = useState<'classic' | 'satellite'>('satellite')
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
const [draggablePoints, setDraggablePoints] = useState(points)
const polylineClickRef = useRef(false)
const positions = waypoints
.sort((a, b) => a.order - b.order)
.map(w => [w.latitude, w.longitude] as [number, number])
useEffect(() => { setDraggablePoints(points) }, [points])
function ClickHandler() {
useMapEvents({
click(e) {
if (actionMode === 'points') {
if (!polylineClickRef.current) onAddPoint(e.latlng.lat, e.latlng.lng)
polylineClickRef.current = false
}
},
})
return null
}
const handlePolylineClick = (e: L.LeafletMouseEvent) => {
if (actionMode === 'points') {
polylineClickRef.current = true
onPolylineClick(e)
}
}
const handleDrag = (index: number, pos: { lat: number; lng: number }) => {
const updated = [...draggablePoints]
updated[index] = { ...updated[index], position: pos }
setDraggablePoints(updated)
}
return (
<MapContainer center={center} zoom={13} className="h-full w-full" key={center.join(',')}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{waypoints.map(wp => (
<Marker key={wp.id} position={[wp.latitude, wp.longitude]} icon={icon}>
<Popup>{wp.name}</Popup>
</Marker>
))}
{positions.length > 1 && <Polyline positions={positions} color="#fd7e14" weight={2} />}
</MapContainer>
<div className="flex-1 relative" ref={containerRef}>
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
<ClickHandler />
<TileLayer
url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite}
attribution={mapType === 'classic' ? '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>' : 'Satellite'}
/>
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
<SetView center={currentPosition} />
{movingPoint && <MiniMap pointPosition={movingPoint} mapType={mapType} />}
{draggablePoints.map((point, index) => (
<MapPoint key={point.id}
point={point} points={draggablePoints} index={index}
mapElement={containerRef.current}
onDrag={handleDrag}
onDragEnd={(i, pos) => onUpdatePoint(i, pos)}
onAltitudeChange={onAltitudeChange}
onMetaChange={onMetaChange}
onRemove={onRemovePoint}
onMoving={setMovingPoint}
/>
))}
{draggablePoints.length > 1 && (
<Polyline
positions={[[draggablePoints[draggablePoints.length - 1].position.lat, draggablePoints[draggablePoints.length - 1].position.lng],
[draggablePoints[0].position.lat, draggablePoints[0].position.lng]]}
color="#228be6" dashArray="5,10"
/>
)}
{currentPosition && (
<Marker position={currentPosition} icon={defaultIcon} draggable
eventHandlers={{ dragend: (e) => onPositionChange((e.target as L.Marker).getLatLng()) }}>
<Popup>{t('flights.planner.currentLocation')}</Popup>
</Marker>
)}
{rectangles.map(rect => (
<Rectangle key={rect.id} bounds={rect.bounds} pathOptions={{ color: rect.color }} />
))}
<DrawControl color={rectangleColor} actionMode={actionMode} rectangles={rectangles} setRectangles={setRectangles} />
</MapContainer>
{(actionMode === 'workArea' || actionMode === 'prohibitedArea') && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-[400] bg-az-panel/90 border border-az-border rounded px-3 py-1 text-[11px] text-az-text pointer-events-none">
Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'}
</div>
)}
<button onClick={() => setMapType(m => m === 'classic' ? 'satellite' : 'classic')}
className={`absolute top-2 right-2 z-[400] px-2 py-1 text-xs rounded border ${
mapType === 'satellite' ? 'bg-az-panel border-az-orange text-white' : 'bg-az-panel border-az-border text-az-text'
}`}>
{t('flights.planner.satellite')}
</button>
</div>
)
}