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 && (
-
-
-
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')}
+
+
+ )
+}
diff --git a/src/features/flights/MapPoint.tsx b/src/features/flights/MapPoint.tsx
new file mode 100644
index 0000000..1128e52
--- /dev/null
+++ b/src/features/flights/MapPoint.tsx
@@ -0,0 +1,87 @@
+import { useRef } from 'react'
+import { Marker, Popup } from 'react-leaflet'
+import { useTranslation } from 'react-i18next'
+import { pointIconGreen, pointIconBlue, pointIconRed } from './mapIcons'
+import { PURPOSES } from './types'
+import type { FlightPoint, MovingPointInfo } from './types'
+import type L from 'leaflet'
+
+interface Props {
+ point: FlightPoint
+ points: FlightPoint[]
+ index: number
+ mapElement: HTMLElement | null
+ onDrag: (index: number, position: { lat: number; lng: number }) => void
+ onDragEnd: (index: number, position: { lat: number; lng: number }) => void
+ onAltitudeChange: (index: number, altitude: number) => void
+ onMetaChange: (index: number, meta: string[]) => void
+ onRemove: (id: string) => void
+ onMoving: (info: MovingPointInfo | null) => void
+}
+
+export default function MapPoint({
+ point, points, index, mapElement,
+ onDrag, onDragEnd, onAltitudeChange, onMetaChange, onRemove, onMoving,
+}: Props) {
+ const { t } = useTranslation()
+ const markerRef = useRef
(null)
+
+ const icon = index === 0 ? pointIconGreen : index === points.length - 1 ? pointIconRed : pointIconBlue
+
+ const handleMove = (e: L.LeafletEvent) => {
+ const marker = markerRef.current
+ if (!marker || !mapElement) return
+ const markerEl = (marker as unknown as { _icon: HTMLElement })._icon
+ if (!markerEl) return
+ const mapRect = mapElement.getBoundingClientRect()
+ const mRect = markerEl.getBoundingClientRect()
+ const dx = mRect.left - mapRect.left + mRect.width > mapRect.width / 2 ? -150 : 200
+ const dy = mRect.top + mRect.height > mapRect.height / 2 ? -150 : 150
+ onMoving({ x: mRect.left - mapRect.left + dx, y: mRect.top - mapRect.top + dy, latlng: (e.target as L.Marker).getLatLng() })
+ }
+
+ const toggleMeta = (value: string) => {
+ const newMeta = point.meta.includes(value) ? point.meta.filter(m => m !== value) : [...point.meta, value]
+ onMetaChange(index, newMeta)
+ }
+
+ return (
+ onDrag(index, (e.target as L.Marker).getLatLng()),
+ dragend: (e) => { onDragEnd(index, (e.target as L.Marker).getLatLng()); onMoving(null) },
+ move: handleMove,
+ }}
+ >
+
+
+
{t('flights.planner.point')} {index + 1}
+
+
+ onAltitudeChange(index, Number(e.target.value))}
+ className="w-full accent-az-orange" />
+ {point.altitude}m
+
+
+ {PURPOSES.map(p => (
+
+ ))}
+
+
onRemove(point.id)}
+ className="text-az-red text-[10px] hover:underline">
+ {t('flights.planner.removePoint')}
+
+
+
+
+ )
+}
diff --git a/src/features/flights/MiniMap.tsx b/src/features/flights/MiniMap.tsx
new file mode 100644
index 0000000..50570d9
--- /dev/null
+++ b/src/features/flights/MiniMap.tsx
@@ -0,0 +1,32 @@
+import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet'
+import { useEffect } from 'react'
+import type L from 'leaflet'
+import { TILE_URLS } from './types'
+import type { MovingPointInfo } from './types'
+
+function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
+ const map = useMap()
+ useEffect(() => { map.setView(latlng) }, [latlng, map])
+ return null
+}
+
+interface Props {
+ pointPosition: MovingPointInfo
+ mapType: 'classic' | 'satellite'
+}
+
+export default function MiniMap({ pointPosition, mapType }: Props) {
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/features/flights/WaypointList.tsx b/src/features/flights/WaypointList.tsx
new file mode 100644
index 0000000..fd47248
--- /dev/null
+++ b/src/features/flights/WaypointList.tsx
@@ -0,0 +1,62 @@
+import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd'
+import { useTranslation } from 'react-i18next'
+import type { FlightPoint, CalculatedPointInfo } from './types'
+
+interface Props {
+ points: FlightPoint[]
+ calculatedPointInfo: CalculatedPointInfo[]
+ onReorder: (points: FlightPoint[]) => void
+ onEdit: (point: FlightPoint) => void
+ onRemove: (id: string) => void
+}
+
+export default function WaypointList({ points, calculatedPointInfo, onReorder, onEdit, onRemove }: Props) {
+ const { t } = useTranslation()
+
+ const handleDragEnd = (result: DropResult) => {
+ if (!result.destination) return
+ const items = Array.from(points)
+ const [moved] = items.splice(result.source.index, 1)
+ items.splice(result.destination.index, 0, moved)
+ onReorder(items)
+ }
+
+ const formatInfo = (info: CalculatedPointInfo | undefined, alt: number) => {
+ if (!info) return `${alt}${t('flights.planner.metres')}`
+ const hours = Math.floor(info.time)
+ const mins = Math.floor((info.time - hours) * 60)
+ const timeStr = hours >= 1 ? `${hours}${t('flights.planner.hour')}${mins}${t('flights.planner.minutes')}` : `${mins}${t('flights.planner.minutes')}`
+ return `${alt}${t('flights.planner.metres')} ${Math.floor(info.bat)}%${t('flights.planner.battery')} ${timeStr}`
+ }
+
+ return (
+
+
+ {(provided) => (
+
+ {points.map((point, index) => (
+
+ {(provided) => (
+
+
+
+ {String(index + 1).padStart(2, '0')}
+
+ {formatInfo(calculatedPointInfo[index], point.altitude)}
+
+
+ onEdit(point)} className="hover:text-az-orange">✎
+ onRemove(point.id)} className="hover:text-az-red">×
+
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+
+ )
+}
diff --git a/src/features/flights/WindEffect.tsx b/src/features/flights/WindEffect.tsx
new file mode 100644
index 0000000..07ba201
--- /dev/null
+++ b/src/features/flights/WindEffect.tsx
@@ -0,0 +1,32 @@
+import { useTranslation } from 'react-i18next'
+import type { WindParams } from './types'
+
+interface Props {
+ wind: WindParams
+ onChange: (wind: WindParams) => void
+}
+
+export default function WindEffect({ wind, onChange }: Props) {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
diff --git a/src/features/flights/flightPlanUtils.ts b/src/features/flights/flightPlanUtils.ts
new file mode 100644
index 0000000..59c47e9
--- /dev/null
+++ b/src/features/flights/flightPlanUtils.ts
@@ -0,0 +1,146 @@
+import type { FlightPoint, CalculatedPointInfo, AircraftParams } from './types'
+
+export function newGuid(): string {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0
+ const v = c === 'x' ? r : (r & 0x3) | 0x8
+ return v.toString(16)
+ })
+}
+
+export function calculateDistance(
+ point1: FlightPoint,
+ point2: FlightPoint,
+ aircraftType: string,
+ initialAltitude: number,
+ downang: number,
+ upang: number,
+): number {
+ if (!point1?.position || !point2?.position) return 0
+
+ const R = 6371
+ const { lat: lat1, lng: lon1 } = point1.position
+ const { lat: lat2, lng: lon2 } = point2.position
+ const alt1 = point1.altitude || 0
+ const alt2 = point2.altitude || 0
+
+ const toRad = (value: number) => (value * Math.PI) / 180
+ const dLat = toRad(lat2 - lat1)
+ const dLon = toRad(lon2 - lon1)
+
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
+ Math.sin(dLon / 2) * Math.sin(dLon / 2)
+
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+ const horizontalDistance = R * c
+
+ const initialAltitudeKm = initialAltitude / 1000
+ const altitude1Km = alt1 / 1000
+ const altitude2Km = alt2 / 1000
+
+ const descentAngleRad = toRad(downang || 0.01)
+ const ascentAngleRad = toRad(upang || 0.01)
+
+ if (aircraftType === 'Plane') {
+ const ascentDist = Math.max(0, (initialAltitudeKm - altitude1Km) / Math.sin(ascentAngleRad))
+ const descentDist = Math.max(0, (initialAltitudeKm - altitude2Km) / Math.sin(descentAngleRad))
+ const hAscent = Math.max(0, ascentDist * Math.cos(ascentAngleRad))
+ const hDescent = Math.max(0, descentDist * Math.cos(descentAngleRad))
+ return horizontalDistance - (hDescent + hAscent) + Math.max(0, descentDist) + Math.max(0, ascentDist)
+ }
+
+ const ascentVertical = Math.abs(initialAltitudeKm - altitude1Km)
+ const descentVertical = Math.abs(initialAltitudeKm - altitude2Km)
+ return ascentVertical + horizontalDistance + descentVertical
+}
+
+export async function getWeatherData(lat: number, lon: number) {
+ const apiKey = '335799082893fad97fa36118b131f919'
+ const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`
+ try {
+ const res = await fetch(url)
+ const data = await res.json()
+ return { windSpeed: data.wind.speed as number, windAngle: data.wind.deg as number }
+ } catch {
+ return null
+ }
+}
+
+export async function calculateBatteryPercentUsed(
+ groundSpeed: number,
+ time: number,
+ position: { lat: number; lon: number },
+ aircraft: AircraftParams,
+): Promise {
+ const weatherData = await getWeatherData(position.lat, position.lon)
+ const airDensity = 1.05
+ const groundSpeedMs = groundSpeed / 3.6
+ const headwind = (weatherData?.windSpeed ?? 0) * Math.cos((Math.PI / 180) * (weatherData?.windAngle ?? 0))
+ const effectiveAirspeed = groundSpeedMs + headwind
+ const drag = 0.5 * airDensity * (effectiveAirspeed ** 2) * aircraft.dragCoefficient * aircraft.frontalArea
+ const adjustedDrag = drag + aircraft.weight * 9.8 * 0.05
+
+ let watts = aircraft.thrustWatts[aircraft.thrustWatts.length - 1].watts
+ for (const item of aircraft.thrustWatts) {
+ const thrustN = (item.thrust / 1000) * 9.8
+ if (thrustN > adjustedDrag) { watts = item.watts; break }
+ }
+ const power = watts / aircraft.propellerEfficiency
+ const energyUsed = power * time
+ return Math.min((energyUsed / aircraft.batteryCapacity) * 100, 100)
+}
+
+export async function calculateAllPoints(
+ points: FlightPoint[],
+ aircraft: AircraftParams,
+ initialAltitude: number,
+): Promise {
+ const infos: CalculatedPointInfo[] = [{ bat: 100, time: 0 }]
+ for (let i = 1; i < points.length; i++) {
+ const p1 = points[i - 1], p2 = points[i]
+ const dist = calculateDistance(p1, p2, aircraft.type, initialAltitude, aircraft.downang, aircraft.upang)
+ const time = dist / aircraft.speed
+ const midPos = { lat: (p1.position.lat + p2.position.lat) / 2, lon: (p1.position.lng + p2.position.lng) / 2 }
+ const pct = await calculateBatteryPercentUsed(aircraft.speed, time, midPos, aircraft)
+ infos.push({ bat: infos[i - 1].bat - pct, time: infos[i - 1].time + time })
+ }
+ return infos
+}
+
+export function parseCoordinates(input: string): { lat: number; lng: number } | null {
+ const cleaned = input.trim().replace(/[°NSEW]/gi, '')
+ const parts = cleaned.split(/[,\s]+/).filter(Boolean)
+ if (parts.length >= 2) {
+ const lat = parseFloat(parts[0])
+ const lng = parseFloat(parts[1])
+ if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
+ return { lat, lng }
+ }
+ }
+ return null
+}
+
+export function getMockAircraftParams(): AircraftParams {
+ return {
+ type: 'Plane',
+ downang: 40,
+ upang: 45,
+ weight: 3.4,
+ speed: 80,
+ frontalArea: 0.12,
+ dragCoefficient: 0.45,
+ batteryCapacity: 315,
+ thrustWatts: [
+ { thrust: 500, watts: 55.5 },
+ { thrust: 750, watts: 91.02 },
+ { thrust: 1000, watts: 137.64 },
+ { thrust: 1250, watts: 191 },
+ { thrust: 1500, watts: 246 },
+ { thrust: 1750, watts: 308 },
+ { thrust: 2000, watts: 381 },
+ ],
+ propellerEfficiency: 0.95,
+ }
+}
diff --git a/src/features/flights/mapIcons.ts b/src/features/flights/mapIcons.ts
new file mode 100644
index 0000000..0629398
--- /dev/null
+++ b/src/features/flights/mapIcons.ts
@@ -0,0 +1,22 @@
+import L from 'leaflet'
+
+function pinIcon(color: string) {
+ return L.divIcon({
+ className: '',
+ html: ``,
+ iconSize: [24, 24],
+ iconAnchor: [12, 24],
+ popupAnchor: [0, -24],
+ })
+}
+
+export const pointIconGreen = pinIcon('#1ed013')
+export const pointIconBlue = pinIcon('#228be6')
+export const pointIconRed = pinIcon('#fa5252')
+
+export const defaultIcon = new L.Icon({
+ iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png',
+ iconSize: [25, 41],
+ iconAnchor: [12, 41],
+ popupAnchor: [1, -34],
+})
diff --git a/src/features/flights/types.ts b/src/features/flights/types.ts
new file mode 100644
index 0000000..5eeb403
--- /dev/null
+++ b/src/features/flights/types.ts
@@ -0,0 +1,58 @@
+import type L from 'leaflet'
+
+export interface FlightPoint {
+ id: string
+ position: { lat: number; lng: number }
+ altitude: number
+ meta: string[]
+}
+
+export interface CalculatedPointInfo {
+ bat: number
+ time: number
+}
+
+export interface MapRectangle {
+ id: string
+ layer?: L.Layer
+ bounds: L.LatLngBounds
+ color: 'red' | 'green'
+}
+
+export interface AircraftParams {
+ type: string
+ downang: number
+ upang: number
+ weight: number
+ speed: number
+ frontalArea: number
+ dragCoefficient: number
+ batteryCapacity: number
+ thrustWatts: { thrust: number; watts: number }[]
+ propellerEfficiency: number
+}
+
+export interface WindParams {
+ direction: number
+ speed: number
+}
+
+export interface MovingPointInfo {
+ x: number
+ y: number
+ latlng: L.LatLng
+}
+
+export type ActionMode = 'points' | 'workArea' | 'prohibitedArea'
+
+export const PURPOSES = [
+ { value: 'tank', label: 'options.tank' },
+ { value: 'artillery', label: 'options.artillery' },
+] as const
+
+export const COORDINATE_PRECISION = 8
+
+export const TILE_URLS = {
+ classic: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+} as const
diff --git a/src/i18n/en.json b/src/i18n/en.json
index b78dcf3..134d720 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -33,7 +33,56 @@
"liveGps": "Live GPS",
"correction": "GPS Correction",
"apply": "Apply",
- "telemetry": "Telemetry"
+ "telemetry": "Telemetry",
+ "planner": {
+ "point": "Point",
+ "altitude": "Altitude",
+ "initialAltitude": "Initial Altitude",
+ "addPoints": "Points",
+ "workArea": "Work Area",
+ "prohibitedArea": "No-Go Zone",
+ "location": "Location",
+ "currentLocation": "Current location",
+ "operations": "Operations",
+ "editAsJson": "Edit JSON",
+ "exportMapData": "Export",
+ "save": "Save",
+ "upload": "Upload",
+ "titleAdd": "Add New Point",
+ "titleEdit": "Edit Point",
+ "description": "Enter the coordinates, altitude, and purpose of the point.",
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "purpose": "Purpose",
+ "cancel": "Cancel",
+ "submitAdd": "Add Point",
+ "submitEdit": "Save Changes",
+ "removePoint": "Delete",
+ "windSpeed": "Wind spd",
+ "windDirection": "Wind dir",
+ "setWind": "Set Wind",
+ "battery": "bat.",
+ "metres": "m",
+ "km": "km",
+ "hour": "h",
+ "minutes": "min",
+ "calculated": "calculated",
+ "error": "Calculation error",
+ "statusGood": "Good",
+ "statusCaution": "Caution",
+ "statusLow": "Can't complete",
+ "options": {
+ "artillery": "Artillery",
+ "tank": "Tank"
+ },
+ "invalidJson": "Invalid JSON format",
+ "editJsonHint": "Edit the JSON data as needed.",
+ "satellite": "Satellite",
+ "cameraFov": "Camera FOV / Length / Field",
+ "cameraFovPlaceholder": "FOV parameters",
+ "commAddr": "Communication Addr / Port",
+ "commAddrPlaceholder": "192.168.1.1:8080"
+ }
},
"annotations": {
"title": "Annotations",
diff --git a/src/i18n/ua.json b/src/i18n/ua.json
index 6125d15..aa0a8c9 100644
--- a/src/i18n/ua.json
+++ b/src/i18n/ua.json
@@ -33,7 +33,56 @@
"liveGps": "GPS Потік",
"correction": "Корекція GPS",
"apply": "Застосувати",
- "telemetry": "Телеметрія"
+ "telemetry": "Телеметрія",
+ "planner": {
+ "point": "Точка",
+ "altitude": "Висота",
+ "initialAltitude": "Початкова висота",
+ "addPoints": "Точки",
+ "workArea": "Робоча зона",
+ "prohibitedArea": "Заборонена зона",
+ "location": "Місцезнаходження",
+ "currentLocation": "Поточне місцезнаходження",
+ "operations": "Операції",
+ "editAsJson": "Редагувати JSON",
+ "exportMapData": "Експорт",
+ "save": "Зберегти",
+ "upload": "Завантажити",
+ "titleAdd": "Додати нову точку",
+ "titleEdit": "Редагувати точку",
+ "description": "Введіть координати, висоту та мету точки.",
+ "latitude": "Широта",
+ "longitude": "Довгота",
+ "purpose": "Мета",
+ "cancel": "Скасувати",
+ "submitAdd": "Додати точку",
+ "submitEdit": "Зберегти зміни",
+ "removePoint": "Видалити",
+ "windSpeed": "Шв. вітру",
+ "windDirection": "Напр. вітру",
+ "setWind": "Вітер",
+ "battery": "бат.",
+ "metres": "м",
+ "km": "км",
+ "hour": "год",
+ "minutes": "хв",
+ "calculated": "розрахункова",
+ "error": "Помилка розрахунку",
+ "statusGood": "Долетить",
+ "statusCaution": "Є ризики",
+ "statusLow": "Не долетить",
+ "options": {
+ "artillery": "Артилерія",
+ "tank": "Танк"
+ },
+ "invalidJson": "Невірний JSON формат",
+ "editJsonHint": "Відредагуйте JSON дані за потреби.",
+ "satellite": "Супутник",
+ "cameraFov": "Камера FOV / Фокус",
+ "cameraFovPlaceholder": "Параметри FOV",
+ "commAddr": "Адреса / Порт",
+ "commAddrPlaceholder": "192.168.1.1:8080"
+ }
},
"annotations": {
"title": "Анотації",
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..9dccf71
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,4 @@
+///
+
+declare module '*.css'
+declare module 'leaflet-polylinedecorator'
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
index 40ea44b..b457a5c 100644
--- a/tsconfig.tsbuildinfo
+++ b/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/dataset/datasetpage.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightspage.tsx","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/dataset/datasetpage.tsx","./src/features/flights/altitudechart.tsx","./src/features/flights/altitudedialog.tsx","./src/features/flights/drawcontrol.tsx","./src/features/flights/flightlistsidebar.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightparamspanel.tsx","./src/features/flights/flightspage.tsx","./src/features/flights/jsoneditordialog.tsx","./src/features/flights/mappoint.tsx","./src/features/flights/minimap.tsx","./src/features/flights/waypointlist.tsx","./src/features/flights/windeffect.tsx","./src/features/flights/flightplanutils.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"}
\ No newline at end of file