mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 05:21:11 +00:00
ff522b0821
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).
414 lines
19 KiB
TypeScript
414 lines
19 KiB
TypeScript
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'
|
||
import { useTranslation } from 'react-i18next'
|
||
import DrawControl from './DrawControl'
|
||
import MapPoint from './MapPoint'
|
||
import MiniMap from './MiniMap'
|
||
import { currentPositionIcon } from './mapIcons'
|
||
import { getTileUrl } from './types'
|
||
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
|
||
|
||
interface MapEventsProps {
|
||
points: FlightPoint[]
|
||
handlePolylineClick: (e: L.LeafletMouseEvent) => void
|
||
containerRef: React.RefObject<HTMLDivElement | null>
|
||
onMapMove: (center: L.LatLng) => void
|
||
}
|
||
|
||
function MapEvents({ points, handlePolylineClick, containerRef, onMapMove }: MapEventsProps) {
|
||
const map = useMap()
|
||
const polylineRef = useRef<L.Polyline | null>(null)
|
||
const arrowRef = useRef<L.FeatureGroup | null>(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: '#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: '#36D6C5' } }) }],
|
||
}).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
|
||
}
|
||
|
||
function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) {
|
||
const m = useMap()
|
||
useEffect(() => { onReady(m) }, [m, onReady])
|
||
return null
|
||
}
|
||
|
||
interface Props {
|
||
points: FlightPoint[]
|
||
calculatedPointInfo: CalculatedPointInfo[]
|
||
currentPosition: { lat: number; lng: number }
|
||
rectangles: MapRectangle[]
|
||
setRectangles: React.Dispatch<React.SetStateAction<MapRectangle[]>>
|
||
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
|
||
// 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) {
|
||
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)
|
||
}
|
||
|
||
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"
|
||
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()}
|
||
crossOrigin="use-credentials"
|
||
attribution="Satellite"
|
||
/>
|
||
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
|
||
<SetView center={currentPosition} />
|
||
<MapRefCapture onReady={handleMapReady} />
|
||
|
||
{movingPoint && <MiniMap pointPosition={movingPoint} />}
|
||
|
||
{draggablePoints.map((point, index) => (
|
||
<MapPoint key={point.id}
|
||
point={point} points={draggablePoints} index={index}
|
||
mapElement={containerRef.current}
|
||
onDrag={handleDrag}
|
||
onDragEnd={(i, pos) => onUpdatePoint(i, pos)}
|
||
onAltitudeChange={onAltitudeChange}
|
||
onMetaChange={onMetaChange}
|
||
onRemove={onRemovePoint}
|
||
onMoving={setMovingPoint}
|
||
/>
|
||
))}
|
||
|
||
{currentPosition && (
|
||
<Marker position={currentPosition} icon={currentPositionIcon} draggable
|
||
eventHandlers={{ dragend: (e) => onPositionChange((e.target as L.Marker).getLatLng()) }}>
|
||
<Popup>{t('flights.planner.currentLocation')}</Popup>
|
||
</Marker>
|
||
)}
|
||
|
||
{rectangles.map(rect => (
|
||
<Rectangle key={rect.id} bounds={rect.bounds} pathOptions={{ color: rect.color }} />
|
||
))}
|
||
|
||
<DrawControl color={rectangleColor} actionMode={actionMode} rectangles={rectangles} setRectangles={setRectangles} />
|
||
</MapContainer>
|
||
|
||
{/* v2 drawing-hint HUD — restyled to v2 tokens */}
|
||
{(actionMode === 'workArea' || actionMode === 'prohibitedArea') && (
|
||
<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>
|
||
)
|
||
}
|