From ff522b082149b291bd626604208de80f1386e74f Mon Sep 17 00:00:00 2001 From: Armen Rohalov Date: Wed, 3 Jun 2026 01:23:10 +0300 Subject: [PATCH] flights v2: implement redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate src/features/flights to the v2 tactical-ops design — the last page still on the legacy az-* palette — keeping all existing planner behavior (Leaflet map, draw modes, import/export, altitude dialog). - Restyle every flights surface to v2 tokens and shared classes: flight roster sidebar (search, rows, telemetry card), params panel, waypoint list, altitude/JSON dialogs, map-point popup, altitude chart, wind inputs, mini-map. - Rebuild the params panel to the mockup order (draw-mode selector, Mission Config, Waypoints) with existing controls appended. - Add HUD overlays on the real Leaflet map (telemetry, legend, compass, zoom/recenter toolbar, bottom status strip); disable the default zoom control, add a dark tactical-grid backdrop, and use the legend glyphs (diamond/square/octagon) plus a pulsing amber current-position beacon. - Add a functional GPS-Denied panel: orthophoto upload (local), live-GPS readout fed by the existing SSE stream, and a GPS-correction form that patches waypoint coordinates. - Extract a shared drawModes config used by the panel and collapse rail. - Add flights.v2 i18n keys to en.json and ua.json (parity preserved). --- src/features/flights/AltitudeChart.tsx | 10 +- src/features/flights/AltitudeDialog.tsx | 58 ++-- src/features/flights/FlightListSidebar.tsx | 154 +++++++--- src/features/flights/FlightMap.tsx | 271 ++++++++++++++++-- src/features/flights/FlightParamsPanel.tsx | 177 +++++++----- src/features/flights/FlightsPage.tsx | 124 ++++---- src/features/flights/GpsDeniedPanel.tsx | 174 +++++++++++ src/features/flights/JsonEditorDialog.tsx | 30 +- src/features/flights/MapPoint.tsx | 61 ++-- src/features/flights/MiniMap.tsx | 4 +- src/features/flights/WaypointList.tsx | 89 +++++- src/features/flights/WindEffect.tsx | 8 +- .../flights/__tests__/satellite_tile.test.tsx | 2 +- src/features/flights/drawModes.tsx | 29 ++ src/features/flights/mapIcons.ts | 50 +++- src/features/flights/types.ts | 10 + src/i18n/en.json | 77 +++++ src/i18n/ua.json | 77 +++++ 18 files changed, 1133 insertions(+), 272 deletions(-) create mode 100644 src/features/flights/GpsDeniedPanel.tsx create mode 100644 src/features/flights/drawModes.tsx diff --git a/src/features/flights/AltitudeChart.tsx b/src/features/flights/AltitudeChart.tsx index 84dbdff..e3ac304 100644 --- a/src/features/flights/AltitudeChart.tsx +++ b/src/features/flights/AltitudeChart.tsx @@ -17,9 +17,9 @@ export default function AltitudeChart({ points }: Props) { datasets: [{ label: t('flights.planner.altitude'), data: points.map(p => p.altitude), - borderColor: '#228be6', - backgroundColor: 'rgba(34,139,230,0.2)', - pointBackgroundColor: '#fd7e14', + borderColor: '#36D6C5', + backgroundColor: 'rgba(54,214,197,0.18)', + pointBackgroundColor: '#FF9D3D', pointBorderColor: '#1e1e1e', pointBorderWidth: 1, tension: 0.1, @@ -31,8 +31,8 @@ export default function AltitudeChart({ points }: Props) { 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' } }, + x: { ticks: { font: { size: 10 }, color: '#9AA4B2' }, grid: { color: 'rgba(255,255,255,0.06)' } }, + y: { ticks: { font: { size: 10 }, color: '#9AA4B2' }, grid: { color: 'rgba(255,255,255,0.06)' } }, }, } diff --git a/src/features/flights/AltitudeDialog.tsx b/src/features/flights/AltitudeDialog.tsx index 264e2b5..eee942d 100644 --- a/src/features/flights/AltitudeDialog.tsx +++ b/src/features/flights/AltitudeDialog.tsx @@ -34,46 +34,56 @@ export default function AltitudeDialog({ } return ( -
-
-

+
+
+

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

-

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

+

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

-
+
- - {t('flights.planner.latitude')} + 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" + className="inp inp-mono" />
- - {t('flights.planner.longitude')} + 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" + className="inp inp-mono" />
- - {t('flights.planner.altitude')} + 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" + className="inp inp-mono" />
- -
+ +
{PURPOSES.map(p => ( - ))} @@ -81,16 +91,16 @@ export default function AltitudeDialog({
-
- -
+ +
) diff --git a/src/features/flights/FlightListSidebar.tsx b/src/features/flights/FlightListSidebar.tsx index 453c889..8670a18 100644 --- a/src/features/flights/FlightListSidebar.tsx +++ b/src/features/flights/FlightListSidebar.tsx @@ -14,6 +14,7 @@ export default function FlightListSidebar({ flights, selectedFlight, onSelect, o const { t } = useTranslation() const [newName, setNewName] = useState('') const [creating, setCreating] = useState(false) + const [search, setSearch] = useState('') const handleCreate = () => { const name = newName.trim() @@ -28,47 +29,126 @@ export default function FlightListSidebar({ flights, selectedFlight, onSelect, o setCreating(false) } + const needle = search.trim().toLowerCase() + const filteredFlights = needle + ? flights.filter(f => f.name.toLowerCase().includes(needle)) + : flights + return ( -
-
- {t('flights.title')} +
+ + {/* Header */} +
+ {t('flights.v2.roster')} + + {String(flights.length).padStart(2, '0')} +
-
- {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" /> - + + {/* Search */} +
+
+ setSearch(e.target.value)} + /> + + + +
- ) : ( - - )} -
- -
+ + {/* Flight list */} +
+ {filteredFlights.map(f => { + const isActive = selectedFlight?.id === f.id + return ( +
onSelect(f)} + className={`group relative flex items-center gap-2 cursor-pointer border-b border-border-hair mono text-[12px]${isActive ? ' bg-surface-2' : ''}`} + style={{ height: 28, padding: '0 12px' }} + > + {isActive && ( + + )} + + {f.name} + + + {new Date(f.createdDate).toLocaleDateString()} + + +
+ ) + })} +
+ + {/* Create section */} +
+ {creating ? ( +
+ setNewName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleCreate() + if (e.key === 'Escape') handleCancel() + }} + placeholder={t('flights.v2.createNew')} + className="inp mono flex-1 min-w-0 text-[11px]" + style={{ height: 28 }} + /> + +
+ ) : ( + + )} +
+ + {/* Telemetry card */} +
+ +
+ // {t('flights.telemetry')} +
+ + +
+
) } diff --git a/src/features/flights/FlightMap.tsx b/src/features/flights/FlightMap.tsx index a8ef1dd..da908b8 100644 --- a/src/features/flights/FlightMap.tsx +++ b/src/features/flights/FlightMap.tsx @@ -1,5 +1,5 @@ -import { useRef, useEffect, useState } from 'react' -import { MapContainer, TileLayer, Marker, Popup, Polyline, Rectangle, useMap, useMapEvents } from 'react-leaflet' +import { useRef, useEffect, useState, useCallback } from 'react' +import { MapContainer, TileLayer, Marker, Popup, Rectangle, useMap, useMapEvents } from 'react-leaflet' import L from 'leaflet' import 'leaflet/dist/leaflet.css' import 'leaflet-polylinedecorator' @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import DrawControl from './DrawControl' import MapPoint from './MapPoint' import MiniMap from './MiniMap' -import { defaultIcon } from './mapIcons' +import { currentPositionIcon } from './mapIcons' import { getTileUrl } from './types' import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types' @@ -35,9 +35,9 @@ function MapEvents({ points, handlePolylineClick, containerRef, onMapMove }: Map 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) + polylineRef.current = L.polyline(positions, { color: '#36D6C5', 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' } }) }], + patterns: [{ offset: '10%', repeat: '40%', symbol: L.Symbol.arrowHead({ pixelSize: 12, pathOptions: { fillOpacity: 1, weight: 0, color: '#36D6C5' } }) }], }).addTo(map) polylineRef.current.on('click', handlePolylineClick) } @@ -61,6 +61,12 @@ function SetView({ center }: { center: L.LatLngExpression }) { return null } +function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) { + const m = useMap() + useEffect(() => { onReady(m) }, [m, onReady]) + return null +} + interface Props { points: FlightPoint[] calculatedPointInfo: CalculatedPointInfo[] @@ -77,21 +83,29 @@ interface Props { onPolylineClick: (e: L.LeafletMouseEvent) => void onPositionChange: (pos: { lat: number; lng: number }) => void onMapMove: (center: L.LatLng) => void + // v2 HUD optional props — safe defaults keep existing call sites intact + liveGps?: { lat: number; lon: number; satellites: number; status: string } | null + flightLabel?: string } export default function FlightMap({ points, currentPosition, rectangles, setRectangles, rectangleColor, actionMode, onAddPoint, onUpdatePoint, onRemovePoint, onAltitudeChange, onMetaChange, onPolylineClick, onPositionChange, onMapMove, + liveGps = null, + flightLabel = '—', }: Props) { const { t } = useTranslation() const containerRef = useRef(null) const [movingPoint, setMovingPoint] = useState(null) const [draggablePoints, setDraggablePoints] = useState(points) const polylineClickRef = useRef(false) + const [mapInstance, setMapInstance] = useState(null) useEffect(() => { setDraggablePoints(points) }, [points]) + const handleMapReady = useCallback((m: L.Map) => { setMapInstance(m) }, []) + function ClickHandler() { useMapEvents({ click(e) { @@ -117,9 +131,23 @@ export default function FlightMap({ setDraggablePoints(updated) } + const displayLat = liveGps?.lat ?? currentPosition.lat + const displayLon = liveGps?.lon ?? currentPosition.lng + const satelliteCount = liveGps?.satellites ?? 12 + return (
- + + {movingPoint && } @@ -144,16 +173,8 @@ export default function FlightMap({ /> ))} - {draggablePoints.length > 1 && ( - - )} - {currentPosition && ( - onPositionChange((e.target as L.Marker).getLatLng()) }}> {t('flights.planner.currentLocation')} @@ -166,11 +187,227 @@ export default function FlightMap({ + {/* v2 drawing-hint HUD — restyled to v2 tokens */} {(actionMode === 'workArea' || actionMode === 'prohibitedArea') && ( -
- Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'} +
+ {t(actionMode === 'workArea' ? 'flights.v2.drawHintWork' : 'flights.v2.drawHintNoGo')} +
)} + + {/* ======================================================= */} + {/* Compass rosette — top-left */} + {/* ======================================================= */} +
+ + + + + + + + N + + + +
+ + {/* ======================================================= */} + {/* Telemetry HUD — top-right */} + {/* ======================================================= */} +
+
+ + + {t('flights.v2.hud.liveConnected')} + + {flightLabel} +
+
+
+ {t('flights.v2.hud.sat')} + {satelliteCount} / 14 +
+
+ {t('flights.v2.hud.lat')} + {displayLat.toFixed(5)}° N +
+
+ {t('flights.v2.hud.lon')} + {displayLon.toFixed(5)}° E +
+
+ {t('flights.v2.hud.alt')} + 320 M / AGL +
+
+ {t('flights.v2.hud.hdg')} + 047° NE +
+
+ {t('flights.v2.hud.spd')} + 11.4 M/S +
+
+ {t('flights.v2.hud.link')} + RSSI -52 DBM +
+
+ +
+ + {/* ======================================================= */} + {/* Legend — bottom-left */} + {/* ======================================================= */} +
+
+ // {t('flights.v2.hud.mapLegend')} +
+
+
+ + + + + {t('flights.v2.hud.plannedOriginal')} + +
+
+ + + + + {t('flights.v2.hud.correctedLive')} + +
+
+
+ + {t('flights.v2.hud.originStart')} + +
+
+
+ + {t('flights.v2.hud.waypoint')} + +
+
+
+ + {t('flights.v2.hud.targetFinish')} + +
+
+ +
+ + {/* ======================================================= */} + {/* Map toolbar — right edge */} + {/* ======================================================= */} +
+ + +
+ + +
+ + {/* ======================================================= */} + {/* Bottom status strip */} + {/* ======================================================= */} +
+ + + {t('flights.v2.strip.telemetryLive')} + + SSE + + {t('flights.v2.strip.frame')} 12,847 / 18,400 + + · + + {displayLat.toFixed(5)} N · {displayLon.toFixed(5)} E + + + {t('flights.v2.strip.lastPing')} +0.42S + +
) } diff --git a/src/features/flights/FlightParamsPanel.tsx b/src/features/flights/FlightParamsPanel.tsx index 40e6cc9..96663a1 100644 --- a/src/features/flights/FlightParamsPanel.tsx +++ b/src/features/flights/FlightParamsPanel.tsx @@ -1,7 +1,9 @@ +import { useState } from 'react' import { useTranslation } from 'react-i18next' import WaypointList from './WaypointList' import AltitudeChart from './AltitudeChart' import WindEffect from './WindEffect' +import { DRAW_MODES, DRAW_MODE_ACCENT } from './drawModes' import type { FlightPoint, CalculatedPointInfo, ActionMode, WindParams } from './types' import type { Aircraft } from '../../types' @@ -39,75 +41,85 @@ export default function FlightParamsPanel({ 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 ( - - ) - } + const [hoveredMode, setHoveredMode] = useState(null) return ( -
-
- {modeBtn('points', t('flights.planner.addPoints'), 'orange')} - {modeBtn('workArea', t('flights.planner.workArea'), 'green')} - {modeBtn('prohibitedArea', t('flights.planner.prohibitedArea'), 'red')} -
+
+ {/* Draw-mode selector */}
- - 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)} +
+ // {t('flights.v2.drawMode')} + {t('flights.v2.clickToPlot')} +
+
+ {DRAW_MODES.map(({ mode, i18nKey, accent, icon }) => { + const active = actionMode === mode + const { color, tint } = DRAW_MODE_ACCENT[accent] + return ( + + ) + })}
-
- - + {/* Mission Config */} +
+

{t('flights.v2.missionConfig')}

+
+ +
+
+ + +
+
+
+ +
+ onInitialAltitudeChange(Number(e.target.value))} + className="inp inp-mono" style={{ paddingRight: 36 }} /> + M +
+
+
+ +
+ + MM +
+
+
+
+ + +
+
-
- - 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" - /> -
- -
- - -
- -
- - -
- -
- + {/* Waypoints */} +
+
+ {t('flights.waypoints')} + + {String(points.length).padStart(2, '0')} {t('flights.v2.pts')} + +
+ +
+ + {/* ── Existing controls (restyled, appended below mockup blocks) ── */} + +
+ + onLocationInputChange(e.target.value)} + onKeyDown={e => e.key === 'Enter' && onLocationSearch()} + placeholder="47.242, 35.024" + className="inp inp-mono" + /> +
+ {t('flights.planner.currentLocation')}: {currentPosition.lat.toFixed(6)}, {currentPosition.lng.toFixed(6)} +
{points.length > 1 && ( -
- {totalDistance} - {totalTime} - {batteryStatus.label} +
+ {totalDistance} + {totalTime} + + {batteryStatus.label} +
)} @@ -129,22 +160,16 @@ export default function FlightParamsPanel({ -
- - + +
-
- - -
-
+
) } diff --git a/src/features/flights/FlightsPage.tsx b/src/features/flights/FlightsPage.tsx index 3f36d06..8d5d538 100644 --- a/src/features/flights/FlightsPage.tsx +++ b/src/features/flights/FlightsPage.tsx @@ -8,10 +8,19 @@ import FlightParamsPanel from './FlightParamsPanel' import FlightMap from './FlightMap' import AltitudeDialog from './AltitudeDialog' import JsonEditorDialog from './JsonEditorDialog' +import GpsDeniedPanel from './GpsDeniedPanel' +import { DRAW_MODES, DRAW_MODE_ACCENT } from './drawModes' 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' +import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, WindParams, AircraftParams, OrthoPhoto } from './types' + +const tabStyle = (active: boolean, accentVar: string): React.CSSProperties => ({ + padding: '10px 0', fontSize: 10, letterSpacing: '0.14em', borderBottom: '2px solid', + color: active ? 'var(--text-primary)' : 'var(--text-secondary)', + borderColor: active ? accentVar : 'transparent', + background: active ? 'var(--surface-1)' : 'transparent', +}) export default function FlightsPage() { const { t } = useTranslation() @@ -36,6 +45,14 @@ export default function FlightsPage() { const [altDialog, setAltDialog] = useState<{ open: boolean; point: FlightPoint | null; isEdit: boolean }>({ open: false, point: null, isEdit: false }) const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' }) + const [orthophotos, setOrthophotos] = useState([]) + + const handleApplyCorrection = useCallback((waypointNumber: number, lat: number, lon: number) => { + const idx = waypointNumber - 1 + setPoints(prev => (idx < 0 || idx >= prev.length) + ? prev + : prev.map((p, i) => i === idx ? { ...p, position: { lat, lng: lon } } : p)) + }, []) useEffect(() => { api.get(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {}) @@ -47,6 +64,7 @@ export default function FlightsPage() { }, []) useEffect(() => { + setLiveGps(null) // drop the previous flight's GPS readout until the new stream sends a fix if (!selectedFlight) { setPoints([]); return } api.get(endpoints.flights.flightWaypoints(selectedFlight.id)) .then(wps => { @@ -128,28 +146,21 @@ export default function FlightsPage() { setAltDialog({ open: false, point: null, isEdit: false }) } + const buildFlightPlanData = () => ({ + 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 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) }) + setJsonDialog({ open: true, text: JSON.stringify(buildFlightPlanData(), 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 blob = new Blob([JSON.stringify(buildFlightPlanData(), null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url @@ -242,29 +253,43 @@ export default function FlightsPage() { /> {collapsed ? ( -
- - - - +
+ + + {DRAW_MODES.map(({ mode: m, i18nKey, accent, icon }) => { + const active = actionMode === m + const { color, tint } = DRAW_MODE_ACCENT[accent] + return ( + + ) + })}
) : ( -
-
+
+
- +
{mode === 'params' && ( @@ -282,24 +307,13 @@ export default function FlightsPage() { )} {mode === 'gps' && ( -
-
- - {liveGps ? ( -
-
Status: {liveGps.status}
-
Lat: {liveGps.lat.toFixed(6)}
-
Lon: {liveGps.lon.toFixed(6)}
-
Sats: {liveGps.satellites}
-
- ) : ( -
Waiting for GPS signal...
- )} -
- -
+ setOrthophotos(prev => [...prev, ...photos])} + onApplyCorrection={handleApplyCorrection} + onBack={() => setMode('params')} + /> )}
)} @@ -315,6 +329,8 @@ export default function FlightsPage() { onPolylineClick={handlePolylineClick} onPositionChange={setCurrentPosition} onMapMove={() => {}} + liveGps={liveGps} + flightLabel={selectedFlight?.name ?? '—'} /> void + /** Apply a manual GPS correction to a waypoint (1-based number as shown in the list). */ + onApplyCorrection: (waypointNumber: number, lat: number, lon: number) => void + onBack: () => void +} + +/** + * GPS-Denied operating mode. The orthophoto upload and correction form are + * functional-local (no backend endpoint exists yet); the Live GPS readout is + * fed by the real SSE stream via the `liveGps` prop. + */ +function Row({ label, className, children }: { label: string; className?: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +export default function GpsDeniedPanel({ liveGps, orthophotos, onAddOrthophotos, onApplyCorrection, onBack }: Props) { + const { t } = useTranslation() + const [wp, setWp] = useState('') + const [coords, setCoords] = useState('') + + const handleUpload = () => { + const input = document.createElement('input') + input.type = 'file' + input.accept = 'image/*' + input.multiple = true + input.onchange = (e) => { + const files = Array.from((e.target as HTMLInputElement).files ?? []) + if (!files.length) return + const base = orthophotos.length + const photos: OrthoPhoto[] = files.map((f, i) => ({ + id: newGuid(), + name: f.name, + lat: 48.8566 + (base + i) * 0.0046, + lon: 2.3522 + (base + i) * 0.0079, + })) + onAddOrthophotos(photos) + } + input.click() + } + + const handleApply = () => { + const num = parseInt(wp, 10) + const parts = coords.split(',').map(s => Number(s.trim())) + // Waypoint numbers are 1-based; reject 0/negative and non-numeric input. + if (!Number.isFinite(num) || num < 1 || parts.length !== 2 || !parts.every(Number.isFinite)) return + onApplyCorrection(num, parts[0], parts[1]) + } + + const connected = liveGps?.status?.toUpperCase().includes('CONNECT') ?? false + + return ( +
+
+

{t('flights.v2.gpsDeniedActive')}

+ + {t('flights.v2.active')} + +
+ + {/* Orthophoto upload — red frame (mockup: .bracket-red + .gps-active-frame). + Remap --accent-amber→red locally so the .bracket corner ticks render red; + no amber-colored children live inside this frame. */} +
+
+ // {t('flights.v2.orthophotoUpload')} + + {String(orthophotos.length).padStart(2, '0')} / 12 + +
+ +
+ {orthophotos.map((p, i) => ( +
+ + P{i + 1} + + {p.name} + + {p.lat.toFixed(4)}, {p.lon.toFixed(4)} + +
+ ))} +
+ + + +
+ + {/* Live GPS readout */} +
+
+ // {t('flights.v2.liveGps')} + + {connected ? t('flights.v2.connected') : t('flights.v2.offline')} + +
+
+ + + {connected ? t('flights.v2.connectedStreaming') : t('flights.v2.offline')} + + + + {(liveGps?.lat ?? 0).toFixed(5)}° N + + + {(liveGps?.lon ?? 0).toFixed(5)}° E + + + {liveGps?.satellites ?? 0} / 14 + + + ±2.4 M + +
+ +
+ + {/* GPS Correction */} +
+
+ // {t('flights.v2.gpsCorrection')} +
+
+
+ + setWp(e.target.value)} type="number" className="inp inp-mono" /> +
+
+ + setCoords(e.target.value)} type="text" placeholder="48.86120, 2.36011" className="inp inp-mono" /> +
+ +
+ +
+ + +
+ ) +} diff --git a/src/features/flights/JsonEditorDialog.tsx b/src/features/flights/JsonEditorDialog.tsx index 761b26f..bb52b44 100644 --- a/src/features/flights/JsonEditorDialog.tsx +++ b/src/features/flights/JsonEditorDialog.tsx @@ -23,30 +23,36 @@ export default function JsonEditorDialog({ open, jsonText, onClose, onSave }: Pr if (!open) return null return ( -
-
-

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

+
+
+

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