From 274800e508ac0368f725436519641039a78541ab Mon Sep 17 00:00:00 2001 From: Oleksandr Hutsul <127889061+Rohalov@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:31:24 +0300 Subject: [PATCH] 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 --- .gitignore | 2 + package.json | 9 + src/features/flights/AltitudeChart.tsx | 44 +++ src/features/flights/AltitudeDialog.tsx | 97 ++++++ src/features/flights/DrawControl.tsx | 76 +++++ src/features/flights/FlightListSidebar.tsx | 74 ++++ src/features/flights/FlightMap.tsx | 196 +++++++++-- src/features/flights/FlightParamsPanel.tsx | 150 +++++++++ src/features/flights/FlightsPage.tsx | 372 +++++++++++++++------ src/features/flights/JsonEditorDialog.tsx | 53 +++ src/features/flights/MapPoint.tsx | 87 +++++ src/features/flights/MiniMap.tsx | 32 ++ src/features/flights/WaypointList.tsx | 62 ++++ src/features/flights/WindEffect.tsx | 32 ++ src/features/flights/flightPlanUtils.ts | 146 ++++++++ src/features/flights/mapIcons.ts | 22 ++ src/features/flights/types.ts | 58 ++++ src/i18n/en.json | 51 ++- src/i18n/ua.json | 51 ++- src/vite-env.d.ts | 4 + tsconfig.tsbuildinfo | 2 +- 21 files changed, 1489 insertions(+), 131 deletions(-) create mode 100644 src/features/flights/AltitudeChart.tsx create mode 100644 src/features/flights/AltitudeDialog.tsx create mode 100644 src/features/flights/DrawControl.tsx create mode 100644 src/features/flights/FlightListSidebar.tsx create mode 100644 src/features/flights/FlightParamsPanel.tsx create mode 100644 src/features/flights/JsonEditorDialog.tsx create mode 100644 src/features/flights/MapPoint.tsx create mode 100644 src/features/flights/MiniMap.tsx create mode 100644 src/features/flights/WaypointList.tsx create mode 100644 src/features/flights/WindEffect.tsx create mode 100644 src/features/flights/flightPlanUtils.ts create mode 100644 src/features/flights/mapIcons.ts create mode 100644 src/features/flights/types.ts create mode 100644 src/vite-env.d.ts diff --git a/.gitignore b/.gitignore index 37d292f..a6427eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .idea +.claude +.superpowers # dependencies /node_modules diff --git a/package.json b/package.json index 7fb0bc9..d95a7fe 100644 --- a/package.json +++ b/package.json @@ -9,17 +9,26 @@ "preview": "vite preview" }, "dependencies": { + "@hello-pangea/dnd": "^18.0.1", + "chart.js": "^4.5.1", "i18next": "^24.2.2", "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", + "leaflet-polylinedecorator": "^1.6.0", + "prop-types": "^15.8.1", "react": "^19.0.0", + "react-chartjs-2": "^5.3.1", "react-dom": "^19.0.0", "react-i18next": "^15.4.1", "react-leaflet": "^5.0.0", + "react-leaflet-draw": "^0.21.0", "react-router-dom": "^7.4.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.1", "@types/leaflet": "^1.9.17", + "@types/leaflet-draw": "^1.0.13", + "@types/leaflet-polylinedecorator": "^1.6.5", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", diff --git a/src/features/flights/AltitudeChart.tsx b/src/features/flights/AltitudeChart.tsx new file mode 100644 index 0000000..84dbdff --- /dev/null +++ b/src/features/flights/AltitudeChart.tsx @@ -0,0 +1,44 @@ +import { Line } from 'react-chartjs-2' +import 'chart.js/auto' +import { useTranslation } from 'react-i18next' +import type { FlightPoint } from './types' + +interface Props { + points: FlightPoint[] +} + +export default function AltitudeChart({ points }: Props) { + const { t } = useTranslation() + + if (points.length === 0) return null + + const data = { + labels: points.map((_, i) => i + 1), + datasets: [{ + label: t('flights.planner.altitude'), + data: points.map(p => p.altitude), + borderColor: '#228be6', + backgroundColor: 'rgba(34,139,230,0.2)', + pointBackgroundColor: '#fd7e14', + pointBorderColor: '#1e1e1e', + pointBorderWidth: 1, + tension: 0.1, + }], + } + + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { font: { size: 10 }, color: '#6c757d' }, grid: { color: '#495057' } }, + y: { ticks: { font: { size: 10 }, color: '#6c757d' }, grid: { color: '#495057' } }, + }, + } + + return ( +
+ +
+ ) +} diff --git a/src/features/flights/AltitudeDialog.tsx b/src/features/flights/AltitudeDialog.tsx new file mode 100644 index 0000000..264e2b5 --- /dev/null +++ b/src/features/flights/AltitudeDialog.tsx @@ -0,0 +1,97 @@ +import { useTranslation } from 'react-i18next' +import { COORDINATE_PRECISION, PURPOSES } from './types' + +interface Props { + open: boolean + isEditMode?: boolean + latitude: number + longitude: number + altitude: number + meta: string[] + onLatitudeChange: (v: number) => void + onLongitudeChange: (v: number) => void + onAltitudeChange: (v: number) => void + onMetaChange: (v: string[]) => void + onSubmit: () => void + onClose: () => void +} + +export default function AltitudeDialog({ + open, isEditMode, latitude, longitude, altitude, meta, + onLatitudeChange, onLongitudeChange, onAltitudeChange, onMetaChange, + onSubmit, onClose, +}: Props) { + const { t } = useTranslation() + if (!open) return null + + const handleCoord = (value: string, setter: (v: number) => void) => { + const n = parseFloat(value) + if (!isNaN(n)) setter(parseFloat(n.toFixed(COORDINATE_PRECISION))) + } + + const toggleMeta = (value: string) => { + onMetaChange(meta.includes(value) ? meta.filter(m => m !== value) : [...meta, value]) + } + + return ( +
+
+

+ {isEditMode ? t('flights.planner.titleEdit') : t('flights.planner.titleAdd')} +

+

{t('flights.planner.description')}

+ +
+
+ + handleCoord(e.target.value, onLatitudeChange)} + className="w-full bg-az-bg border border-az-border rounded px-2 py-1.5 text-az-text outline-none focus:border-az-orange" + /> +
+
+ + handleCoord(e.target.value, onLongitudeChange)} + className="w-full bg-az-bg border border-az-border rounded px-2 py-1.5 text-az-text outline-none focus:border-az-orange" + /> +
+
+ + onAltitudeChange(Number(e.target.value))} + className="w-full bg-az-bg border border-az-border rounded px-2 py-1.5 text-az-text outline-none focus:border-az-orange" + /> +
+
+ +
+ {PURPOSES.map(p => ( + + ))} +
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/src/features/flights/DrawControl.tsx b/src/features/flights/DrawControl.tsx new file mode 100644 index 0000000..03a50cf --- /dev/null +++ b/src/features/flights/DrawControl.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef } from 'react' +import L from 'leaflet' +import { useMap } from 'react-leaflet' +import type { MapRectangle, ActionMode } from './types' +import { newGuid } from './flightPlanUtils' + +interface Props { + color: string + actionMode: ActionMode + rectangles: MapRectangle[] + setRectangles: React.Dispatch> +} + +export default function DrawControl({ color, actionMode, setRectangles }: Props) { + const map = useMap() + const startRef = useRef(null) + const previewRef = useRef(null) + const colorRef = useRef(color) + + useEffect(() => { colorRef.current = color }, [color]) + + useEffect(() => { + const drawing = actionMode === 'workArea' || actionMode === 'prohibitedArea' + if (!drawing) return + + const container = map.getContainer() + const prevCursor = container.style.cursor + container.style.cursor = 'crosshair' + + const onDown = (e: L.LeafletMouseEvent) => { + startRef.current = e.latlng + map.dragging.disable() + if (previewRef.current) { map.removeLayer(previewRef.current); previewRef.current = null } + } + + const onMove = (e: L.LeafletMouseEvent) => { + if (!startRef.current) return + const bounds = L.latLngBounds(startRef.current, e.latlng) + if (previewRef.current) { + previewRef.current.setBounds(bounds) + } else { + previewRef.current = L.rectangle(bounds, { color: colorRef.current, weight: 2, fillOpacity: 0.2 }).addTo(map) + } + } + + const onUp = (e: L.LeafletMouseEvent) => { + if (!startRef.current) return + const start = startRef.current + const end = e.latlng + startRef.current = null + map.dragging.enable() + if (previewRef.current) { map.removeLayer(previewRef.current); previewRef.current = null } + + if (Math.abs(start.lat - end.lat) < 1e-6 && Math.abs(start.lng - end.lng) < 1e-6) return + const bounds = L.latLngBounds(start, end) + const layer = L.rectangle(bounds, { color: colorRef.current, weight: 2, fillOpacity: 0.2 }).addTo(map) + setRectangles(prev => [...prev, { id: newGuid(), layer, color: colorRef.current as 'red' | 'green', bounds }]) + } + + map.on('mousedown', onDown) + map.on('mousemove', onMove) + map.on('mouseup', onUp) + + return () => { + map.off('mousedown', onDown) + map.off('mousemove', onMove) + map.off('mouseup', onUp) + container.style.cursor = prevCursor + map.dragging.enable() + if (previewRef.current) { map.removeLayer(previewRef.current); previewRef.current = null } + startRef.current = null + } + }, [actionMode, map, setRectangles]) + + return null +} diff --git a/src/features/flights/FlightListSidebar.tsx b/src/features/flights/FlightListSidebar.tsx new file mode 100644 index 0000000..453c889 --- /dev/null +++ b/src/features/flights/FlightListSidebar.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { Flight } from '../../types' + +interface Props { + flights: Flight[] + selectedFlight: Flight | null + onSelect: (flight: Flight) => void + onCreate: (name: string) => void + onDelete: (id: string) => void +} + +export default function FlightListSidebar({ flights, selectedFlight, onSelect, onCreate, onDelete }: Props) { + const { t } = useTranslation() + const [newName, setNewName] = useState('') + const [creating, setCreating] = useState(false) + + const handleCreate = () => { + const name = newName.trim() + if (!name) { setCreating(false); return } + onCreate(name) + setNewName('') + setCreating(false) + } + + const handleCancel = () => { + setNewName('') + setCreating(false) + } + + return ( +
+
+ {t('flights.title')} +
+
+ {flights.map(f => ( +
onSelect(f)} + className={`px-2 py-1.5 cursor-pointer border-b border-az-border text-xs ${ + selectedFlight?.id === f.id ? 'bg-az-bg text-white' : 'text-az-text hover:bg-az-bg' + }`}> +
+ {f.name} + +
+
{new Date(f.createdDate).toLocaleDateString()}
+
+ ))} +
+ {creating ? ( +
+ setNewName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleCreate() + if (e.key === 'Escape') handleCancel() + }} + placeholder="Flight name" + className="flex-1 min-w-0 bg-az-bg border border-az-border rounded px-2 py-1.5 text-xs text-az-text outline-none focus:border-az-orange" /> + +
+ ) : ( + + )} +
+ + +
+
+ ) +} diff --git a/src/features/flights/FlightMap.tsx b/src/features/flights/FlightMap.tsx index 2ffffc6..194c0d2 100644 --- a/src/features/flights/FlightMap.tsx +++ b/src/features/flights/FlightMap.tsx @@ -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 + onMapMove: (center: L.LatLng) => void +} + +function MapEvents({ points, handlePolylineClick, containerRef, onMapMove }: MapEventsProps) { + const map = useMap() + const polylineRef = useRef(null) + const arrowRef = useRef(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> + 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(null) + const [mapType, setMapType] = useState<'classic' | 'satellite'>('satellite') + const [movingPoint, setMovingPoint] = useState(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 ( - - - {waypoints.map(wp => ( - - {wp.name} - - ))} - {positions.length > 1 && } - +
+ + + OSM' : 'Satellite'} + /> + + + + {movingPoint && } + + {draggablePoints.map((point, index) => ( + onUpdatePoint(i, pos)} + onAltitudeChange={onAltitudeChange} + onMetaChange={onMetaChange} + onRemove={onRemovePoint} + onMoving={setMovingPoint} + /> + ))} + + {draggablePoints.length > 1 && ( + + )} + + {currentPosition && ( + onPositionChange((e.target as L.Marker).getLatLng()) }}> + {t('flights.planner.currentLocation')} + + )} + + {rectangles.map(rect => ( + + ))} + + + + + {(actionMode === 'workArea' || actionMode === 'prohibitedArea') && ( +
+ Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'} +
+ )} + + +
) } diff --git a/src/features/flights/FlightParamsPanel.tsx b/src/features/flights/FlightParamsPanel.tsx new file mode 100644 index 0000000..40e6cc9 --- /dev/null +++ b/src/features/flights/FlightParamsPanel.tsx @@ -0,0 +1,150 @@ +import { useTranslation } from 'react-i18next' +import WaypointList from './WaypointList' +import AltitudeChart from './AltitudeChart' +import WindEffect from './WindEffect' +import type { FlightPoint, CalculatedPointInfo, ActionMode, WindParams } from './types' +import type { Aircraft } from '../../types' + +interface Props { + points: FlightPoint[] + calculatedPointInfo: CalculatedPointInfo[] + aircrafts: Aircraft[] + initialAltitude: number + actionMode: ActionMode + wind: WindParams + locationInput: string + currentPosition: { lat: number; lng: number } + totalDistance: string + totalTime: string + batteryStatus: { label: string; color: string } + onInitialAltitudeChange: (v: number) => void + onActionModeChange: (mode: ActionMode) => void + onWindChange: (w: WindParams) => void + onLocationInputChange: (v: string) => void + onLocationSearch: () => void + onReorderPoints: (points: FlightPoint[]) => void + onEditPoint: (point: FlightPoint) => void + onRemovePoint: (id: string) => void + onSave: () => void + onUpload: () => void + onEditJson: () => void + onExport: () => void +} + +export default function FlightParamsPanel({ + points, calculatedPointInfo, aircrafts, initialAltitude, actionMode, wind, + locationInput, currentPosition, totalDistance, totalTime, batteryStatus, + onInitialAltitudeChange, onActionModeChange, onWindChange, + onLocationInputChange, onLocationSearch, onReorderPoints, onEditPoint, onRemovePoint, + onSave, onUpload, onEditJson, onExport, +}: Props) { + const { t } = useTranslation() + + const modeBtn = (mode: ActionMode, label: string, color: 'orange' | 'green' | 'red') => { + const active = actionMode === mode + const colorMap = { + orange: { border: 'border-az-orange', text: 'text-az-orange', bg: 'bg-az-orange/20', hover: 'hover:bg-az-orange/10' }, + green: { border: 'border-az-green', text: 'text-az-green', bg: 'bg-az-green/20', hover: 'hover:bg-az-green/10' }, + red: { border: 'border-az-red', text: 'text-az-red', bg: 'bg-az-red/20', hover: 'hover:bg-az-red/10' }, + }[color] + return ( + + ) + } + + return ( +
+
+ {modeBtn('points', t('flights.planner.addPoints'), 'orange')} + {modeBtn('workArea', t('flights.planner.workArea'), 'green')} + {modeBtn('prohibitedArea', t('flights.planner.prohibitedArea'), 'red')} +
+ +
+ + onLocationInputChange(e.target.value)} + onKeyDown={e => e.key === 'Enter' && onLocationSearch()} + placeholder="47.242, 35.024" + className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text outline-none focus:border-az-orange" + /> +
+ {t('flights.planner.currentLocation')}: {currentPosition.lat.toFixed(6)}, {currentPosition.lng.toFixed(6)} +
+
+ +
+ + +
+ +
+ + onInitialAltitudeChange(Number(e.target.value))} + className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text outline-none focus:border-az-orange" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {points.length > 1 && ( +
+ {totalDistance} + {totalTime} + {batteryStatus.label} +
+ )} + + + + + +
+ + +
+
+ + +
+
+ ) +} diff --git a/src/features/flights/FlightsPage.tsx b/src/features/flights/FlightsPage.tsx index f434462..cbdbff7 100644 --- a/src/features/flights/FlightsPage.tsx +++ b/src/features/flights/FlightsPage.tsx @@ -1,46 +1,82 @@ import { useState, useEffect, useCallback } from 'react' import { useTranslation } from 'react-i18next' +import L from 'leaflet' import { useFlight } from '../../components/FlightContext' import { api } from '../../api/client' import { createSSE } from '../../api/sse' -import { useResizablePanel } from '../../hooks/useResizablePanel' import ConfirmDialog from '../../components/ConfirmDialog' -import type { Flight, Waypoint, Aircraft } from '../../types' +import FlightListSidebar from './FlightListSidebar' +import FlightParamsPanel from './FlightParamsPanel' import FlightMap from './FlightMap' +import AltitudeDialog from './AltitudeDialog' +import JsonEditorDialog from './JsonEditorDialog' +import { newGuid, calculateDistance, calculateAllPoints, parseCoordinates, getMockAircraftParams } from './flightPlanUtils' +import { PURPOSES } from './types' +import type { Aircraft, Waypoint } from '../../types' +import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, WindParams, AircraftParams } from './types' export default function FlightsPage() { const { t } = useTranslation() const { flights, selectedFlight, selectFlight, refreshFlights } = useFlight() + const [mode, setMode] = useState<'params' | 'gps'>('params') - const [waypoints, setWaypoints] = useState([]) + const [deleteId, setDeleteId] = useState(null) + const [collapsed, setCollapsed] = useState(false) + const [aircrafts, setAircrafts] = useState([]) const [liveGps, setLiveGps] = useState<{ lat: number; lon: number; satellites: number; status: string } | null>(null) - const [deleteId, setDeleteId] = useState(null) - const [newName, setNewName] = useState('') - const leftPanel = useResizablePanel(200, 150, 350) + + const [aircraft, setAircraft] = useState(null) + const [points, setPoints] = useState([]) + const [calculatedPointInfo, setCalculatedPointInfo] = useState([]) + const [rectangles, setRectangles] = useState([]) + const [actionMode, setActionMode] = useState('points') + const [wind, setWind] = useState({ direction: 0, speed: 0 }) + const [initialAltitude, setInitialAltitude] = useState(1000) + const [currentPosition, setCurrentPosition] = useState<{ lat: number; lng: number }>({ lat: 47.242, lng: 35.024 }) + const [locationInput, setLocationInput] = useState('') + + const [altDialog, setAltDialog] = useState<{ open: boolean; point: FlightPoint | null; isEdit: boolean }>({ open: false, point: null, isEdit: false }) + const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' }) useEffect(() => { api.get('/api/flights/aircrafts').then(setAircrafts).catch(() => {}) + setAircraft(getMockAircraftParams()) + navigator.geolocation.getCurrentPosition( + (pos) => setCurrentPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }), + () => {}, + ) }, []) useEffect(() => { - if (!selectedFlight) { setWaypoints([]); return } - api.get(`/api/flights/${selectedFlight.id}/waypoints`).then(setWaypoints).catch(() => {}) + if (!selectedFlight) { setPoints([]); return } + api.get(`/api/flights/${selectedFlight.id}/waypoints`) + .then(wps => { + setPoints(wps.sort((a, b) => a.order - b.order).map(wp => ({ + id: wp.id, + position: { lat: wp.latitude, lng: wp.longitude }, + altitude: 300, + meta: [PURPOSES[0].value, PURPOSES[1].value], + }))) + }) + .catch(() => {}) }, [selectedFlight]) useEffect(() => { if (!selectedFlight || mode !== 'gps') return - return createSSE(`/api/flights/${selectedFlight.id}/live-gps`, (data: any) => setLiveGps(data)) + return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(`/api/flights/${selectedFlight.id}/live-gps`, (data) => setLiveGps(data)) }, [selectedFlight, mode]) - const handleCreate = async () => { - if (!newName.trim()) return - await api.post('/api/flights', { name: newName.trim() }) - setNewName('') + useEffect(() => { + if (!aircraft || points.length < 2) { setCalculatedPointInfo([{ bat: 100, time: 0 }]); return } + calculateAllPoints(points, aircraft, initialAltitude).then(setCalculatedPointInfo) + }, [points, aircraft, initialAltitude]) + + const handleCreateFlight = async (name: string) => { + await api.post('/api/flights', { name }) refreshFlights() } - - const handleDelete = async () => { + const handleDeleteFlight = async () => { if (!deleteId) return await api.delete(`/api/flights/${deleteId}`) if (selectedFlight?.id === deleteId) selectFlight(null) @@ -48,105 +84,203 @@ export default function FlightsPage() { refreshFlights() } - const handleAddWaypoint = async () => { - if (!selectedFlight) return - await api.post(`/api/flights/${selectedFlight.id}/waypoints`, { - name: `Point ${waypoints.length}`, - latitude: 50.45, longitude: 30.52, order: waypoints.length, + const addPoint = useCallback((lat: number, lng: number) => { + const pt: FlightPoint = { id: newGuid(), position: { lat, lng }, altitude: initialAltitude, meta: [PURPOSES[0].value, PURPOSES[1].value] } + setPoints(prev => [...prev, pt]) + }, [initialAltitude]) + + const updatePointPosition = useCallback((index: number, pos: { lat: number; lng: number }) => { + setPoints(prev => prev.map((p, i) => i === index ? { ...p, position: pos } : p)) + }, []) + + const removePoint = useCallback((id: string) => { + setPoints(prev => prev.filter(p => p.id !== id)) + }, []) + + const handlePolylineClick = useCallback((e: L.LeafletMouseEvent) => { + const click = e.latlng + let closestIdx = -1, minDist = Infinity + points.forEach((p, i) => { + if (i < points.length - 1) { + const dist = L.LineUtil.pointToSegmentDistance( + L.point(click.lng, click.lat), + L.point(p.position.lng, p.position.lat), + L.point(points[i + 1].position.lng, points[i + 1].position.lat), + ) + if (dist < minDist) { minDist = dist; closestIdx = i } + } }) - const wps = await api.get(`/api/flights/${selectedFlight.id}/waypoints`) - setWaypoints(wps) + if (closestIdx !== -1) { + const alt = (points[closestIdx].altitude + points[closestIdx + 1].altitude) / 2 + const pt: FlightPoint = { id: newGuid(), position: { lat: click.lat, lng: click.lng }, altitude: alt, meta: [PURPOSES[0].value, PURPOSES[1].value] } + setPoints(prev => { const u = [...prev]; u.splice(closestIdx + 1, 0, pt); return u }) + } + }, [points]) + + const openEditDialog = (point: FlightPoint) => { + setAltDialog({ open: true, point: { ...point }, isEdit: true }) + } + const handleAltSubmit = () => { + if (!altDialog.point) return + if (altDialog.isEdit) { + setPoints(prev => prev.map(p => p.id === altDialog.point!.id ? altDialog.point! : p)) + } else { + setPoints(prev => [...prev, altDialog.point!]) + } + setAltDialog({ open: false, point: null, isEdit: false }) } - const handleDeleteWaypoint = async (wpId: string) => { - if (!selectedFlight) return - await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wpId}`) - setWaypoints(prev => prev.filter(w => w.id !== wpId)) + const handleEditJson = () => { + const data = { + operational_height: { currentAltitude: initialAltitude }, + geofences: { polygons: rectangles.map(r => { + const sw = r.bounds.getSouthWest(), ne = r.bounds.getNorthEast() + return { northWest: { lat: ne.lat, lon: sw.lng }, southEast: { lat: sw.lat, lon: ne.lng }, fence_type: r.color === 'red' ? 'EXCLUSION' : 'INCLUSION' } + })}, + action_points: points.map(p => ({ point: { lat: p.position.lat, lon: p.position.lng }, height: p.altitude, action: 'search', action_specific: { targets: p.meta } })), + } + setJsonDialog({ open: true, text: JSON.stringify(data, null, 2) }) } + const handleExport = () => { + const data = { + operational_height: { currentAltitude: initialAltitude }, + geofences: { polygons: rectangles.map(r => { + const sw = r.bounds.getSouthWest(), ne = r.bounds.getNorthEast() + return { northWest: { lat: ne.lat, lon: sw.lng }, southEast: { lat: sw.lat, lon: ne.lng }, fence_type: r.color === 'red' ? 'EXCLUSION' : 'INCLUSION' } + })}, + action_points: points.map(p => ({ point: { lat: p.position.lat, lon: p.position.lng }, height: p.altitude, action: 'search', action_specific: { targets: p.meta } })), + } + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${selectedFlight?.name ?? 'flight-plan'}.json` + a.click() + URL.revokeObjectURL(url) + } + + const handleImport = () => { + const input = document.createElement('input') + input.type = 'file' + input.accept = 'application/json' + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (!file) return + const text = await file.text() + handleJsonSave(text) + } + input.click() + } + + const handleJsonSave = (json: string) => { + try { + const data = JSON.parse(json) + if (data.action_points) { + setPoints(data.action_points.map((ap: Record) => ({ + id: newGuid(), + position: { + lat: (ap.point as Record)?.lat ?? (ap.lat as number), + lng: (ap.point as Record)?.lon ?? (ap.lon as number), + }, + altitude: (ap.height as number) || 300, + meta: (ap.action_specific as Record)?.targets || [PURPOSES[0].value, PURPOSES[1].value], + }))) + } + if (data.geofences?.polygons) { + setRectangles(data.geofences.polygons.map((p: { southEast: { lat: number; lon: number }; northWest: { lat: number; lon: number }; fence_type?: string }) => ({ + id: newGuid(), + bounds: L.latLngBounds([p.southEast.lat, p.northWest.lon], [p.northWest.lat, p.southEast.lon]), + color: p.fence_type === 'EXCLUSION' ? 'red' as const : 'green' as const, + }))) + } + if (data.operational_height?.currentAltitude) setInitialAltitude(data.operational_height.currentAltitude) + setJsonDialog({ open: false, text: '' }) + } catch { alert(t('flights.planner.invalidJson')) } + } + + const handleSave = async () => { + if (!selectedFlight) return + const existing = await api.get(`/api/flights/${selectedFlight.id}/waypoints`).catch(() => [] as Waypoint[]) + for (const wp of existing) { + await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wp.id}`).catch(() => {}) + } + for (let i = 0; i < points.length; i++) { + await api.post(`/api/flights/${selectedFlight.id}/waypoints`, { + name: `Point ${i + 1}`, + latitude: points[i].position.lat, + longitude: points[i].position.lng, + order: i, + }).catch(() => {}) + } + } + + const handleLocationSearch = () => { + const coords = parseCoordinates(locationInput) + if (coords) setCurrentPosition(coords) + } + + const lastInfo = calculatedPointInfo[calculatedPointInfo.length - 1] + const batteryStatus = !lastInfo ? { label: '', color: '#6c757d' } + : lastInfo.bat > 12 ? { label: t('flights.planner.statusGood'), color: '#40c057' } + : lastInfo.bat > 5 ? { label: t('flights.planner.statusCaution'), color: '#FFFF00' } + : { label: t('flights.planner.statusLow'), color: '#fa5252' } + + const totalDist = aircraft && points.length > 1 + ? points.reduce((acc, p, i) => i === 0 ? 0 : acc + calculateDistance(points[i - 1], p, aircraft.type, initialAltitude, aircraft.downang, aircraft.upang), 0).toFixed(1) + t('flights.planner.km') + : '' + const totalTimeStr = lastInfo && lastInfo.time > 0 + ? `${Math.floor(lastInfo.time) >= 1 ? Math.floor(lastInfo.time) + t('flights.planner.hour') : ''}${Math.floor((lastInfo.time - Math.floor(lastInfo.time)) * 60)}${t('flights.planner.minutes')}` + : '' + return (
- {/* Flight list sidebar */} -
-
-
- setNewName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleCreate()} - placeholder={t('flights.create')} - className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none" - /> - -
-
-
- {flights.map(f => ( -
selectFlight(f)} - className={`px-2 py-1.5 cursor-pointer border-b border-az-border text-sm ${ - selectedFlight?.id === f.id ? 'bg-az-bg text-white' : 'text-az-text hover:bg-az-bg' - }`} - > -
- {f.name} - -
-
{new Date(f.createdDate).toLocaleDateString()}
-
- ))} -
-
+ setDeleteId(id)} + /> - {/* Resize handle */} -
- - {/* Left params panel */} - {selectedFlight && ( -
-
- + + + +
+ ) : ( +
+
+ - +
{mode === 'params' && ( -
-
- - -
-
- - -
-
-
- - -
-
- {waypoints.map(wp => ( -
- {wp.name} - -
- ))} -
-
-
+ )} {mode === 'gps' && ( @@ -172,16 +306,46 @@ export default function FlightsPage() {
)} - {/* Map view */} -
- -
+ setPoints(prev => prev.map((p, idx) => idx === i ? { ...p, altitude: alt } : p))} + onMetaChange={(i, meta) => setPoints(prev => prev.map((p, idx) => idx === i ? { ...p, meta } : p))} + onPolylineClick={handlePolylineClick} + onPositionChange={setCurrentPosition} + onMapMove={() => {}} + /> + + setAltDialog(prev => ({ ...prev, point: prev.point ? { ...prev.point, position: { ...prev.point.position, lat: v } } : null }))} + onLongitudeChange={v => setAltDialog(prev => ({ ...prev, point: prev.point ? { ...prev.point, position: { ...prev.point.position, lng: v } } : null }))} + onAltitudeChange={v => setAltDialog(prev => ({ ...prev, point: prev.point ? { ...prev.point, altitude: v } : null }))} + onMetaChange={v => setAltDialog(prev => ({ ...prev, point: prev.point ? { ...prev.point, meta: v } : null }))} + onSubmit={handleAltSubmit} + onClose={() => setAltDialog({ open: false, point: null, isEdit: false })} + /> + + setJsonDialog({ open: false, text: '' })} + onSave={handleJsonSave} + /> setDeleteId(null)} />
diff --git a/src/features/flights/JsonEditorDialog.tsx b/src/features/flights/JsonEditorDialog.tsx new file mode 100644 index 0000000..761b26f --- /dev/null +++ b/src/features/flights/JsonEditorDialog.tsx @@ -0,0 +1,53 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' + +interface Props { + open: boolean + jsonText: string + onClose: () => void + onSave: (json: string) => void +} + +export default function JsonEditorDialog({ open, jsonText, onClose, onSave }: Props) { + const { t } = useTranslation() + const [edited, setEdited] = useState(jsonText) + const [valid, setValid] = useState(true) + + useEffect(() => { setEdited(jsonText) }, [jsonText]) + + const handleChange = (value: string) => { + setEdited(value) + try { JSON.parse(value); setValid(true) } catch { setValid(false) } + } + + if (!open) return null + + return ( +
+
+

{t('flights.planner.editAsJson')}

+