-
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 */}
+ {/* ======================================================= */}
+
+
+
+
+
+ {/* ======================================================= */}
+ {/* 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 ? (
-
-
setCollapsed(false)} title="Expand"
- className="w-8 h-8 rounded border border-az-border text-az-text hover:border-az-orange hover:text-az-orange text-sm">»
-
setActionMode('points')} title={t('flights.planner.addPoints')}
- className={`w-8 h-8 rounded border text-sm ${actionMode === 'points' ? 'border-az-orange text-az-orange bg-az-orange/20' : 'border-az-border text-az-text hover:border-az-orange'}`}>●
-
setActionMode('workArea')} title={t('flights.planner.workArea')}
- className={`w-8 h-8 rounded border text-az-green text-sm ${actionMode === 'workArea' ? 'border-az-green bg-az-green/20' : 'border-az-border hover:border-az-green'}`}>▣
-
setActionMode('prohibitedArea')} title={t('flights.planner.prohibitedArea')}
- className={`w-8 h-8 rounded border text-az-red text-sm ${actionMode === 'prohibitedArea' ? 'border-az-red bg-az-red/20' : 'border-az-border hover:border-az-red'}`}>▣
+
+ setCollapsed(false)} title={t('flights.v2.expandParams')}
+ className="ibtn mono" style={{ width: 32, height: 32 }}>»
+
+ {DRAW_MODES.map(({ mode: m, i18nKey, accent, icon }) => {
+ const active = actionMode === m
+ const { color, tint } = DRAW_MODE_ACCENT[accent]
+ return (
+ setActionMode(m)} title={t(i18nKey)} className="mono"
+ style={{
+ width: 32, height: 32, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ border: `1px solid ${color}`, color, borderRadius: 2, cursor: 'pointer',
+ background: active ? tint : 'transparent',
+ boxShadow: active ? `inset 0 0 0 1px ${color}` : 'none',
+ }}>
+ {icon}
+
+ )
+ })}
) : (
-
-
+
+
setMode('params')}
- className={`flex-1 py-1.5 text-[10px] ${mode === 'params' ? 'bg-az-bg text-white' : 'text-az-muted'}`}>
- {t('flights.params')}
+ className="flex-1 mono uppercase"
+ style={tabStyle(mode === 'params', 'var(--accent-amber)')}>
+ {t('flights.v2.flightParams')}
setMode('gps')}
- className={`flex-1 py-1.5 text-[10px] ${mode === 'gps' ? 'bg-az-bg text-white' : 'text-az-muted'}`}>
- {t('flights.gpsDenied')}
+ className="flex-1 mono uppercase"
+ style={tabStyle(mode === 'gps', 'var(--accent-red)')}>
+ {t('flights.v2.gpsDenied')}
- setCollapsed(true)} title="Collapse"
- className="px-2 text-az-muted hover:text-az-orange text-sm border-l border-az-border">«
+ setCollapsed(true)} title={t('flights.v2.collapse')}
+ className="ibtn mono shrink-0 self-center mx-1" style={{ width: 26, height: 26 }}>«
{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...
- )}
-
-
setMode('params')} className="text-az-orange text-xs">
- ← {t('flights.back')}
-
-
+
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)}
+
+
+ ))}
+
+
+
+
+ {t('flights.v2.uploadPhotos')}
+
+
+
+
+ {/* 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" />
+
+
{t('flights.v2.applyCorrection')}
+
+
+
+
+ ‹ {t('flights.v2.backToParams')}
+
+ )
+}
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')}
)
diff --git a/src/features/flights/MapPoint.tsx b/src/features/flights/MapPoint.tsx
index 1128e52..88f06be 100644
--- a/src/features/flights/MapPoint.tsx
+++ b/src/features/flights/MapPoint.tsx
@@ -1,7 +1,7 @@
import { useRef } from 'react'
import { Marker, Popup } from 'react-leaflet'
import { useTranslation } from 'react-i18next'
-import { pointIconGreen, pointIconBlue, pointIconRed } from './mapIcons'
+import { wpStartIcon, wpMidIcon, wpFinishIcon } from './mapIcons'
import { PURPOSES } from './types'
import type { FlightPoint, MovingPointInfo } from './types'
import type L from 'leaflet'
@@ -26,7 +26,7 @@ export default function MapPoint({
const { t } = useTranslation()
const markerRef = useRef
(null)
- const icon = index === 0 ? pointIconGreen : index === points.length - 1 ? pointIconRed : pointIconBlue
+ const icon = index === 0 ? wpStartIcon : index === points.length - 1 ? wpFinishIcon : wpMidIcon
const handleMove = (e: L.LeafletEvent) => {
const marker = markerRef.current
@@ -58,26 +58,55 @@ export default function MapPoint({
}}
>
-
-
{t('flights.planner.point')} {index + 1}
-
-
-
onAltitudeChange(index, Number(e.target.value))}
- className="w-full accent-az-orange" />
-
{point.altitude}m
+
+
+ {t('flights.planner.point')} {index + 1}
-
diff --git a/src/features/flights/MiniMap.tsx b/src/features/flights/MiniMap.tsx
index 2cee107..92b509f 100644
--- a/src/features/flights/MiniMap.tsx
+++ b/src/features/flights/MiniMap.tsx
@@ -17,13 +17,13 @@ interface Props {
export default function MiniMap({ pointPosition }: Props) {
return (
-
+
diff --git a/src/features/flights/WaypointList.tsx b/src/features/flights/WaypointList.tsx
index fd47248..b63777c 100644
--- a/src/features/flights/WaypointList.tsx
+++ b/src/features/flights/WaypointList.tsx
@@ -29,25 +29,94 @@ export default function WaypointList({ points, calculatedPointInfo, onReorder, o
return `${alt}${t('flights.planner.metres')} ${Math.floor(info.bat)}%${t('flights.planner.battery')} ${timeStr}`
}
+ const renderMarker = (index: number) => {
+ if (index === 0) {
+ return (
+
+ )
+ }
+ if (index === points.length - 1 && points.length > 1) {
+ return (
+
+ )
+ }
+ return (
+
+ )
+ }
+
return (
{(provided) => (
-
+
{points.map((point, index) => (
{(provided) => (
-
-
-
- {String(index + 1).padStart(2, '0')}
-
+
+
+ {String(index + 1).padStart(2, '0')}
+
+
+ {renderMarker(index)}
+
+
{formatInfo(calculatedPointInfo[index], point.altitude)}
-
- onEdit(point)} className="hover:text-az-orange">✎
- onRemove(point.id)} className="hover:text-az-red">×
+
+
+ onEdit(point)}
+ className="ibtn edit"
+ style={{ width: 22, height: 22 }}
+ title={t('flights.planner.edit')}
+ >
+ ✎
+
+ onRemove(point.id)}
+ className="ibtn danger"
+ style={{ width: 22, height: 22 }}
+ title={t('flights.planner.remove')}
+ >
+ ×
+
)}
diff --git a/src/features/flights/WindEffect.tsx b/src/features/flights/WindEffect.tsx
index 07ba201..79c1a7a 100644
--- a/src/features/flights/WindEffect.tsx
+++ b/src/features/flights/WindEffect.tsx
@@ -12,19 +12,19 @@ export default function WindEffect({ wind, onChange }: Props) {
return (
diff --git a/src/features/flights/__tests__/satellite_tile.test.tsx b/src/features/flights/__tests__/satellite_tile.test.tsx
index 4151fd2..0f89f17 100644
--- a/src/features/flights/__tests__/satellite_tile.test.tsx
+++ b/src/features/flights/__tests__/satellite_tile.test.tsx
@@ -85,7 +85,7 @@ vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('leaflet-polylinedecorator', () => ({}))
vi.mock('../DrawControl', () => ({ default: () => null }))
vi.mock('../MapPoint', () => ({ default: () => null }))
-vi.mock('../mapIcons', () => ({ defaultIcon: {} }))
+vi.mock('../mapIcons', () => ({ currentPositionIcon: {} }))
import FlightMap from '../FlightMap'
import MiniMap from '../MiniMap'
diff --git a/src/features/flights/drawModes.tsx b/src/features/flights/drawModes.tsx
new file mode 100644
index 0000000..0af383a
--- /dev/null
+++ b/src/features/flights/drawModes.tsx
@@ -0,0 +1,29 @@
+import type { ActionMode } from './types'
+
+export type DrawAccent = 'amber' | 'green' | 'red'
+
+/** Accent color + active-state tint per draw mode. Shared by the collapsed rail
+ * (FlightsPage) and the expanded draw-mode selector (FlightParamsPanel). */
+export const DRAW_MODE_ACCENT: Record = {
+ amber: { color: 'var(--accent-amber)', tint: 'rgba(255,157,61,0.20)' },
+ green: { color: 'var(--accent-green)', tint: 'rgba(61,220,132,0.18)' },
+ red: { color: 'var(--accent-red)', tint: 'rgba(255,71,86,0.18)' },
+}
+
+/** Single source of truth for the three flight-plan draw modes: the action mode,
+ * its i18n label key, accent, and icon. Consumed by both the icon-only collapsed
+ * rail and the labelled expanded selector. */
+export const DRAW_MODES: { mode: ActionMode; i18nKey: string; accent: DrawAccent; icon: React.ReactNode }[] = [
+ {
+ mode: 'points', i18nKey: 'flights.v2.points', accent: 'amber',
+ icon: ,
+ },
+ {
+ mode: 'workArea', i18nKey: 'flights.v2.workArea', accent: 'green',
+ icon: ,
+ },
+ {
+ mode: 'prohibitedArea', i18nKey: 'flights.v2.noGoZone', accent: 'red',
+ icon: ,
+ },
+]
diff --git a/src/features/flights/mapIcons.ts b/src/features/flights/mapIcons.ts
index 61440be..e9baed5 100644
--- a/src/features/flights/mapIcons.ts
+++ b/src/features/flights/mapIcons.ts
@@ -1,23 +1,45 @@
import L from 'leaflet'
-import markerIcon from 'leaflet/dist/images/marker-icon.png'
-function pinIcon(color: string) {
+// v2 waypoint glyphs — match the map legend shapes exactly:
+// start → green diamond (.wp-diamond)
+// middle → cyan-bordered square (.wp-square)
+// finish → red octagon (.wp-octagon)
+function glyphIcon(html: string, size: number) {
return L.divIcon({
className: '',
- html: ``,
- iconSize: [24, 24],
- iconAnchor: [12, 24],
- popupAnchor: [0, -24],
+ html: `${html}
`,
+ iconSize: [size, size],
+ iconAnchor: [size / 2, size / 2],
+ popupAnchor: [0, -(size / 2) - 2],
})
}
-export const pointIconGreen = pinIcon('#1ed013')
-export const pointIconBlue = pinIcon('#228be6')
-export const pointIconRed = pinIcon('#fa5252')
+export const wpStartIcon = glyphIcon(
+ ``,
+ 20,
+)
+export const wpMidIcon = glyphIcon(
+ ``,
+ 16,
+)
+export const wpFinishIcon = glyphIcon(
+ ``,
+ 18,
+)
-export const defaultIcon = new L.Icon({
- iconUrl: markerIcon,
- iconSize: [25, 41],
- iconAnchor: [12, 41],
- popupAnchor: [1, -34],
+// v2 current-position beacon: amber center dot with an expanding pulse ring.
+// Self-contained SVG/SMIL animation so it needs no global CSS keyframes.
+export const currentPositionIcon = L.divIcon({
+ className: '',
+ html: ``,
+ iconSize: [34, 34],
+ iconAnchor: [17, 17],
+ popupAnchor: [0, -17],
})
diff --git a/src/features/flights/types.ts b/src/features/flights/types.ts
index 5ae7aba..6f0f2b4 100644
--- a/src/features/flights/types.ts
+++ b/src/features/flights/types.ts
@@ -37,6 +37,16 @@ export interface WindParams {
speed: number
}
+// Local-only orthophoto entry for the GPS-Denied upload list. There is no
+// backend endpoint for orthophoto upload yet, so this lives entirely in
+// component state (see GpsDeniedPanel / FlightsPage).
+export interface OrthoPhoto {
+ id: string
+ name: string
+ lat: number
+ lon: number
+}
+
export interface MovingPointInfo {
x: number
y: number
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 0ad77b4..a361bae 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -34,6 +34,81 @@
"correction": "GPS Correction",
"apply": "Apply",
"telemetry": "Telemetry",
+ "v2": {
+ "roster": "Flight Roster",
+ "search": "Search flights",
+ "draft": "Draft",
+ "createNew": "Create New",
+ "missionConfig": "Mission Config",
+ "drawMode": "Draw Mode",
+ "clickToPlot": "click map to plot",
+ "points": "Points",
+ "workArea": "Work Area",
+ "noGoZone": "No-Go Zone",
+ "aircraft": "Aircraft",
+ "defaultHeight": "Default Height",
+ "focalLength": "Focal Length",
+ "commAddr": "Comm Address / Port",
+ "pts": "PTS",
+ "wpStart": "START",
+ "wpFinish": "FINISH",
+ "tagOrigin": "ORIGIN",
+ "tagTrack": "TRACK",
+ "tagConfirm": "CONFIRM",
+ "tagTarget": "TARGET",
+ "tagMilVeh": "MIL-VEH",
+ "flightParams": "Flight Params",
+ "gpsDenied": "GPS-Denied",
+ "gpsDeniedActive": "GPS-Denied // Active",
+ "orthophotoUpload": "Orthophoto Upload",
+ "uploadPhotos": "Upload Photos",
+ "liveGps": "Live GPS",
+ "connected": "CONNECTED",
+ "connectedStreaming": "CONNECTED · STREAMING",
+ "active": "Active",
+ "offline": "Offline",
+ "status": "Status",
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "satellites": "Satellites",
+ "drift": "Drift",
+ "gpsCorrection": "GPS Correction",
+ "waypointNum": "Waypoint #",
+ "correctedGps": "Corrected GPS",
+ "applyCorrection": "Apply Correction",
+ "backToParams": "Back to Flight Params",
+ "upload": "Upload",
+ "expandParams": "Expand parameters",
+ "collapse": "Collapse",
+ "date": "Date",
+ "drawHintWork": "Click and drag on the map to draw a work area",
+ "drawHintNoGo": "Click and drag on the map to draw a no-go zone",
+ "hud": {
+ "liveConnected": "LIVE · CONNECTED",
+ "sat": "Sat",
+ "lat": "Lat",
+ "lon": "Lon",
+ "alt": "Alt",
+ "hdg": "Hdg",
+ "spd": "Spd",
+ "link": "Link",
+ "mapLegend": "Map Legend",
+ "plannedOriginal": "Planned · Original",
+ "correctedLive": "Corrected · Live",
+ "originStart": "Origin / Start",
+ "waypoint": "Waypoint",
+ "targetFinish": "Target / Finish",
+ "zoomIn": "Zoom in",
+ "zoomOut": "Zoom out",
+ "recenter": "Recenter",
+ "layers": "Layers"
+ },
+ "strip": {
+ "telemetryLive": "TELEMETRY · LIVE",
+ "frame": "FRAME",
+ "lastPing": "LAST PING"
+ }
+ },
"planner": {
"point": "Point",
"altitude": "Altitude",
@@ -58,6 +133,8 @@
"submitAdd": "Add Point",
"submitEdit": "Save Changes",
"removePoint": "Delete",
+ "edit": "Edit point",
+ "remove": "Remove point",
"windSpeed": "Wind spd",
"windDirection": "Wind dir",
"setWind": "Set Wind",
diff --git a/src/i18n/ua.json b/src/i18n/ua.json
index 670db7b..1fdd32c 100644
--- a/src/i18n/ua.json
+++ b/src/i18n/ua.json
@@ -34,6 +34,81 @@
"correction": "Корекція GPS",
"apply": "Застосувати",
"telemetry": "Телеметрія",
+ "v2": {
+ "roster": "Реєстр польотів",
+ "search": "Пошук польотів",
+ "draft": "Чернетка",
+ "createNew": "Створити новий",
+ "missionConfig": "Конфігурація місії",
+ "drawMode": "Режим малювання",
+ "clickToPlot": "клікніть на карту",
+ "points": "Точки",
+ "workArea": "Робоча зона",
+ "noGoZone": "Заборонена зона",
+ "aircraft": "Літальний апарат",
+ "defaultHeight": "Висота за замовч.",
+ "focalLength": "Фокусна відстань",
+ "commAddr": "Адреса / Порт зв'язку",
+ "pts": "ТЧК",
+ "wpStart": "СТАРТ",
+ "wpFinish": "ФІНІШ",
+ "tagOrigin": "ПОЧАТОК",
+ "tagTrack": "ТРЕК",
+ "tagConfirm": "ПІДТВ.",
+ "tagTarget": "ЦІЛЬ",
+ "tagMilVeh": "ВІЙСЬК-ТЕХ",
+ "flightParams": "Параметри польоту",
+ "gpsDenied": "GPS-Denied",
+ "gpsDeniedActive": "GPS-Denied // Активно",
+ "orthophotoUpload": "Завантаження ортофото",
+ "uploadPhotos": "Завантажити фото",
+ "liveGps": "GPS Потік",
+ "connected": "З'ЄДНАНО",
+ "connectedStreaming": "З'ЄДНАНО · ПОТІК",
+ "active": "Активно",
+ "offline": "Офлайн",
+ "status": "Статус",
+ "latitude": "Широта",
+ "longitude": "Довгота",
+ "satellites": "Супутники",
+ "drift": "Відхилення",
+ "gpsCorrection": "Корекція GPS",
+ "waypointNum": "Точка №",
+ "correctedGps": "Скориговані GPS",
+ "applyCorrection": "Застосувати корекцію",
+ "backToParams": "Назад до параметрів",
+ "upload": "Завантажити",
+ "expandParams": "Розгорнути параметри",
+ "collapse": "Згорнути",
+ "date": "Дата",
+ "drawHintWork": "Клікніть і потягніть на карті, щоб намалювати робочу зону",
+ "drawHintNoGo": "Клікніть і потягніть на карті, щоб намалювати заборонену зону",
+ "hud": {
+ "liveConnected": "ЕФІР · З'ЄДНАНО",
+ "sat": "Супут",
+ "lat": "Шир",
+ "lon": "Довг",
+ "alt": "Вис",
+ "hdg": "Курс",
+ "spd": "Швид",
+ "link": "Зв'язок",
+ "mapLegend": "Легенда карти",
+ "plannedOriginal": "Планований · Оригінал",
+ "correctedLive": "Скоригований · Ефір",
+ "originStart": "Початок / Старт",
+ "waypoint": "Точка маршруту",
+ "targetFinish": "Ціль / Фініш",
+ "zoomIn": "Збільшити",
+ "zoomOut": "Зменшити",
+ "recenter": "Центрувати",
+ "layers": "Шари"
+ },
+ "strip": {
+ "telemetryLive": "ТЕЛЕМЕТРІЯ · ЕФІР",
+ "frame": "КАДР",
+ "lastPing": "ОСТ. ПІНГ"
+ }
+ },
"planner": {
"point": "Точка",
"altitude": "Висота",
@@ -58,6 +133,8 @@
"submitAdd": "Додати точку",
"submitEdit": "Зберегти зміни",
"removePoint": "Видалити",
+ "edit": "Редагувати точку",
+ "remove": "Видалити точку",
"windSpeed": "Шв. вітру",
"windDirection": "Напр. вітру",
"setWind": "Вітер",