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 ConfirmDialog from '../../components/ConfirmDialog' 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 [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 [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) { 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<{ lat: number; lon: number; satellites: number; status: string }>(`/api/flights/${selectedFlight.id}/live-gps`, (data) => setLiveGps(data)) }, [selectedFlight, mode]) 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 handleDeleteFlight = async () => { if (!deleteId) return await api.delete(`/api/flights/${deleteId}`) if (selectedFlight?.id === deleteId) selectFlight(null) setDeleteId(null) refreshFlights() } 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 } } }) 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 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 (
setDeleteId(id)} /> {collapsed ? (
) : (
{mode === 'params' && ( )} {mode === 'gps' && (
{liveGps ? (
Status: {liveGps.status}
Lat: {liveGps.lat.toFixed(6)}
Lon: {liveGps.lon.toFixed(6)}
Sats: {liveGps.satellites}
) : (
Waiting for GPS signal...
)}
)}
)} 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)} />
) }