flights v2: implement redesign
ci/woodpecker/push/build-arm Pipeline failed

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).
This commit is contained in:
Armen Rohalov
2026-06-03 01:23:10 +03:00
parent dfcdc26630
commit ff522b0821
18 changed files with 1133 additions and 272 deletions
+254 -17
View File
@@ -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<HTMLDivElement>(null)
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
const [draggablePoints, setDraggablePoints] = useState(points)
const polylineClickRef = useRef(false)
const [mapInstance, setMapInstance] = useState<L.Map | null>(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 (
<div className="flex-1 relative" ref={containerRef}>
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
<MapContainer center={currentPosition} zoom={15} className="h-full w-full"
zoomControl={false} attributionControl={false}
style={{
backgroundColor: '#0F1318',
backgroundImage:
'linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),' +
'linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px),' +
'radial-gradient(ellipse at 30% 40%, rgba(54,214,197,0.04), transparent 60%),' +
'radial-gradient(ellipse at 80% 70%, rgba(255,157,61,0.03), transparent 65%)',
backgroundSize: '60px 60px, 60px 60px, 100% 100%, 100% 100%',
}}>
<ClickHandler />
<TileLayer
url={getTileUrl()}
@@ -128,6 +156,7 @@ export default function FlightMap({
/>
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
<SetView center={currentPosition} />
<MapRefCapture onReady={handleMapReady} />
{movingPoint && <MiniMap pointPosition={movingPoint} />}
@@ -144,16 +173,8 @@ export default function FlightMap({
/>
))}
{draggablePoints.length > 1 && (
<Polyline
positions={[[draggablePoints[draggablePoints.length - 1].position.lat, draggablePoints[draggablePoints.length - 1].position.lng],
[draggablePoints[0].position.lat, draggablePoints[0].position.lng]]}
color="#228be6" dashArray="5,10"
/>
)}
{currentPosition && (
<Marker position={currentPosition} icon={defaultIcon} draggable
<Marker position={currentPosition} icon={currentPositionIcon} draggable
eventHandlers={{ dragend: (e) => onPositionChange((e.target as L.Marker).getLatLng()) }}>
<Popup>{t('flights.planner.currentLocation')}</Popup>
</Marker>
@@ -166,11 +187,227 @@ export default function FlightMap({
<DrawControl color={rectangleColor} actionMode={actionMode} rectangles={rectangles} setRectangles={setRectangles} />
</MapContainer>
{/* v2 drawing-hint HUD — restyled to v2 tokens */}
{(actionMode === 'workArea' || actionMode === 'prohibitedArea') && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-[400] bg-az-panel/90 border border-az-border rounded px-3 py-1 text-[11px] text-az-text pointer-events-none">
Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'}
<div
className="top-2 left-1/2 -translate-x-1/2 bracket panel micro pointer-events-none"
style={{ position: 'absolute', zIndex: 500, padding: '4px 12px', color: 'var(--accent-amber)', background: 'rgba(19,23,28,0.92)' }}
>
{t(actionMode === 'workArea' ? 'flights.v2.drawHintWork' : 'flights.v2.drawHintNoGo')}
<span className="br" />
</div>
)}
{/* ======================================================= */}
{/* Compass rosette — top-left */}
{/* ======================================================= */}
<div
className="bracket panel flex items-center justify-center pointer-events-none"
style={{ position: 'absolute', top: 48, left: 16, width: 80, height: 80, background: 'rgba(19,23,28,0.6)', backdropFilter: 'blur(2px)', zIndex: 500 }}
>
<svg width="60" height="60" viewBox="-30 -30 60 60" style={{ color: 'var(--accent-amber)' }}>
<circle r="24" fill="none" stroke="currentColor" strokeOpacity="0.3" strokeWidth="0.7" />
<circle r="20" fill="none" stroke="currentColor" strokeOpacity="0.2" strokeWidth="0.5" />
<line x1="0" y1="-26" x2="0" y2="-20" stroke="currentColor" strokeWidth="1.5" />
<line x1="0" y1="20" x2="0" y2="26" stroke="currentColor" strokeOpacity="0.4" strokeWidth="0.8" />
<line x1="-26" y1="0" x2="-20" y2="0" stroke="currentColor" strokeOpacity="0.4" strokeWidth="0.8" />
<line x1="20" y1="0" x2="26" y2="0" stroke="currentColor" strokeOpacity="0.4" strokeWidth="0.8" />
<text x="0" y="-12" textAnchor="middle" fontFamily="JetBrains Mono" fontSize="7" fill="currentColor" fontWeight="700">N</text>
<polygon points="0,-16 -3,-8 0,-10 3,-8" fill="currentColor" />
</svg>
<span className="br" />
</div>
{/* ======================================================= */}
{/* Telemetry HUD — top-right */}
{/* ======================================================= */}
<div
className="bracket panel"
style={{ position: 'absolute', top: 16, right: 16, width: 240, background: 'rgba(19,23,28,0.92)', backdropFilter: 'blur(4px)', padding: 12, zIndex: 500 }}
>
<header
className="flex items-center justify-between"
style={{ marginBottom: 10, paddingBottom: 8, borderBottom: '1px solid var(--border-hair)' }}
>
<span
className="flex items-center gap-2 mono"
style={{ fontSize: 10, color: 'var(--accent-cyan)', letterSpacing: '0.14em' }}
>
<span
className="w-1.5 h-1.5 rounded-full live"
style={{ background: 'var(--accent-cyan)' }}
/>
{t('flights.v2.hud.liveConnected')}
</span>
<span className="micro" style={{ color: 'var(--text-muted)' }}>{flightLabel}</span>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div className="flex items-center justify-between">
<span className="micro">{t('flights.v2.hud.sat')}</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--accent-green)' }}>{satelliteCount} / 14</span>
</div>
<div className="flex items-center justify-between">
<span className="micro">{t('flights.v2.hud.lat')}</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>{displayLat.toFixed(5)}° N</span>
</div>
<div className="flex items-center justify-between">
<span className="micro">{t('flights.v2.hud.lon')}</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>{displayLon.toFixed(5)}° E</span>
</div>
<div className="flex items-center justify-between">
<span className="micro">{t('flights.v2.hud.alt')}</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>320 M / AGL</span>
</div>
<div className="flex items-center justify-between">
<span className="micro">{t('flights.v2.hud.hdg')}</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--accent-amber)' }}>047° NE</span>
</div>
<div className="flex items-center justify-between">
<span className="micro">{t('flights.v2.hud.spd')}</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>11.4 M/S</span>
</div>
<div
className="flex items-center justify-between"
style={{ paddingTop: 6, marginTop: 6, borderTop: '1px solid var(--border-hair)' }}
>
<span className="micro">{t('flights.v2.hud.link')}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--accent-green)' }}>RSSI -52 DBM</span>
</div>
</div>
<span className="br" />
</div>
{/* ======================================================= */}
{/* Legend — bottom-left */}
{/* ======================================================= */}
<div
className="bracket panel pointer-events-none"
style={{ position: 'absolute', bottom: 48, left: 16, width: 200, background: 'rgba(19,23,28,0.92)', padding: 12, zIndex: 500 }}
>
<header style={{ marginBottom: 8, paddingBottom: 6, borderBottom: '1px solid var(--border-hair)' }}>
<span className="sect-head">// {t('flights.v2.hud.mapLegend')}</span>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11 }}>
<div className="flex items-center gap-2.5">
<svg width="22" height="6">
<line x1="0" y1="3" x2="22" y2="3" stroke="#FF4756" strokeWidth="1.5" strokeDasharray="3 3" />
</svg>
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
{t('flights.v2.hud.plannedOriginal')}
</span>
</div>
<div className="flex items-center gap-2.5">
<svg width="22" height="6">
<line x1="0" y1="3" x2="22" y2="3" stroke="#36D6C5" strokeWidth="2" />
</svg>
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
{t('flights.v2.hud.correctedLive')}
</span>
</div>
<div
className="flex items-center gap-2.5"
style={{ paddingTop: 6, borderTop: '1px solid var(--border-hair)' }}
>
<div style={{ width: 10, height: 10, background: 'var(--accent-green)', transform: 'rotate(45deg)', flexShrink: 0 }} />
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
{t('flights.v2.hud.originStart')}
</span>
</div>
<div className="flex items-center gap-2.5">
<div style={{ width: 10, height: 10, background: 'transparent', border: '1.5px solid var(--accent-cyan)', flexShrink: 0 }} />
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
{t('flights.v2.hud.waypoint')}
</span>
</div>
<div className="flex items-center gap-2.5">
<div style={{ width: 11, height: 11, background: 'var(--accent-red)', clipPath: 'polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%)', flexShrink: 0 }} />
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
{t('flights.v2.hud.targetFinish')}
</span>
</div>
</div>
<span className="br" />
</div>
{/* ======================================================= */}
{/* Map toolbar — right edge */}
{/* ======================================================= */}
<div
className="absolute flex flex-col gap-1.5 pointer-events-auto"
style={{ top: '50%', right: 16, transform: 'translateY(-50%)', zIndex: 500 }}
>
<button
className="flex items-center justify-center border border-border-hair panel mono"
style={{ width: 32, height: 32, color: 'var(--text-primary)', fontSize: 16, background: 'var(--surface-1)' }}
title={t('flights.v2.hud.zoomIn')}
onClick={() => mapInstance?.zoomIn()}
type="button"
>
+
</button>
<button
className="flex items-center justify-center border border-border-hair panel mono"
style={{ width: 32, height: 32, color: 'var(--text-primary)', fontSize: 16, background: 'var(--surface-1)' }}
title={t('flights.v2.hud.zoomOut')}
onClick={() => mapInstance?.zoomOut()}
type="button"
>
</button>
<div style={{ width: 32, height: 1, background: 'var(--border-hair)' }} />
<button
className="flex items-center justify-center border border-border-hair panel"
style={{ width: 32, height: 32, color: 'var(--accent-amber)', background: 'var(--surface-1)' }}
title={t('flights.v2.hud.recenter')}
onClick={() => mapInstance?.setView([currentPosition.lat, currentPosition.lng])}
type="button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<circle cx="12" cy="12" r="3" />
<circle cx="12" cy="12" r="8" />
<line x1="12" y1="2" x2="12" y2="4" />
<line x1="12" y1="20" x2="12" y2="22" />
<line x1="2" y1="12" x2="4" y2="12" />
<line x1="20" y1="12" x2="22" y2="12" />
</svg>
</button>
<button
className="flex items-center justify-center border border-border-hair panel"
style={{ width: 32, height: 32, color: 'var(--text-secondary)', background: 'var(--surface-1)' }}
title={t('flights.v2.hud.layers')}
type="button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
</button>
</div>
{/* ======================================================= */}
{/* Bottom status strip */}
{/* ======================================================= */}
<div
className="absolute left-0 right-0 flex items-center gap-4 border-t border-border-hair pointer-events-none"
style={{ bottom: 0, height: 28, padding: '0 12px', background: 'var(--surface-1)', zIndex: 500 }}
>
<span className="pill pill-green">
<span className="dot live" />
{t('flights.v2.strip.telemetryLive')}
</span>
<span className="micro" style={{ color: 'var(--text-muted)' }}>SSE</span>
<span className="mono micro" style={{ color: 'var(--text-secondary)' }}>
{t('flights.v2.strip.frame')} 12,847 / 18,400
</span>
<span className="micro" style={{ color: 'var(--text-muted)' }}>·</span>
<span className="mono micro" style={{ color: 'var(--text-secondary)' }}>
{displayLat.toFixed(5)} N · {displayLon.toFixed(5)} E
</span>
<span className="ml-auto micro" style={{ color: 'var(--text-muted)' }}>
{t('flights.v2.strip.lastPing')} +0.42S
</span>
</div>
</div>
)
}