mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 15:31:11 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff522b0821 |
@@ -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)' } },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -34,46 +34,56 @@ export default function AltitudeDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[2000]">
|
||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 shadow-xl">
|
||||
<h3 className="text-white font-semibold mb-1">
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[2000]" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
||||
<div className="bracket panel w-96 shadow-xl" style={{ background: 'var(--surface-1)', padding: '20px' }}>
|
||||
<h3 className="sect-head mb-1">
|
||||
{isEditMode ? t('flights.planner.titleEdit') : t('flights.planner.titleAdd')}
|
||||
</h3>
|
||||
<p className="text-az-muted text-xs mb-3">{t('flights.planner.description')}</p>
|
||||
<p className="micro mb-4" style={{ textTransform: 'none', letterSpacing: 'normal', color: 'var(--text-secondary)' }}>
|
||||
{t('flights.planner.description')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-az-muted block mb-0.5">{t('flights.planner.latitude')}</label>
|
||||
<input type="number" step="any"
|
||||
<label className="micro block mb-1">{t('flights.planner.latitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={latitude.toFixed(COORDINATE_PRECISION)}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted block mb-0.5">{t('flights.planner.longitude')}</label>
|
||||
<input type="number" step="any"
|
||||
<label className="micro block mb-1">{t('flights.planner.longitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={longitude.toFixed(COORDINATE_PRECISION)}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted block mb-0.5">{t('flights.planner.altitude')}</label>
|
||||
<input type="number"
|
||||
<label className="micro block mb-1">{t('flights.planner.altitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={altitude}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted block mb-1">{t('flights.planner.purpose')}</label>
|
||||
<div className="flex gap-3">
|
||||
<label className="micro block mb-2">{t('flights.planner.purpose')}</label>
|
||||
<div className="flex gap-4">
|
||||
{PURPOSES.map(p => (
|
||||
<label key={p.value} className="flex items-center gap-1.5 cursor-pointer text-az-text">
|
||||
<input type="checkbox" checked={meta.includes(p.value)}
|
||||
<label key={p.value} className="flex items-center gap-1.5 cursor-pointer text-text-primary text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={meta.includes(p.value)}
|
||||
onChange={() => toggleMeta(p.value)}
|
||||
className="rounded border-az-border bg-az-bg accent-az-orange" />
|
||||
style={{ accentColor: 'var(--accent-amber)' }}
|
||||
/>
|
||||
{t(`flights.planner.${p.label}`)}
|
||||
</label>
|
||||
))}
|
||||
@@ -81,16 +91,16 @@ export default function AltitudeDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={onClose}
|
||||
className="px-3 py-1 text-sm border border-az-border rounded hover:bg-az-bg text-az-text">
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<button onClick={onClose} className="btn btn-ghost">
|
||||
{t('flights.planner.cancel')}
|
||||
</button>
|
||||
<button onClick={onSubmit}
|
||||
className="px-3 py-1 text-sm bg-az-orange rounded hover:bg-orange-600 text-white">
|
||||
<button onClick={onSubmit} className="btn btn-primary">
|
||||
{isEditMode ? t('flights.planner.submitEdit') : t('flights.planner.submitAdd')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="br" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-az-panel border-r border-az-border flex flex-col shrink-0 w-[160px]">
|
||||
<div className="px-2 py-2 border-b border-az-border text-[10px] text-az-muted uppercase tracking-wide">
|
||||
{t('flights.title')}
|
||||
<div className="w-[210px] shrink-0 flex flex-col border-r border-border-hair bg-surface-1">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2.5 flex items-center justify-between border-b border-border-hair">
|
||||
<span className="sect-head">{t('flights.v2.roster')}</span>
|
||||
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>
|
||||
{String(flights.length).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{flights.map(f => (
|
||||
<div key={f.id} onClick={() => 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'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate">{f.name}</span>
|
||||
<button onClick={e => { e.stopPropagation(); onDelete(f.id) }}
|
||||
className="text-az-muted hover:text-az-red text-xs">×</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-az-muted">{new Date(f.createdDate).toLocaleDateString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{creating ? (
|
||||
<div className="flex gap-1 mx-3 my-2">
|
||||
<input autoFocus value={newName} onChange={e => 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" />
|
||||
<button onClick={handleCreate} className="shrink-0 bg-az-blue text-white text-xs px-3 py-1.5 rounded hover:brightness-110">OK</button>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-3 py-2 border-b border-border-hair">
|
||||
<div className="relative">
|
||||
<input
|
||||
className="inp mono text-[11px]"
|
||||
style={{ height: 28, letterSpacing: '0.08em', paddingLeft: 28 }}
|
||||
placeholder={t('flights.v2.search')}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2"
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setCreating(true)}
|
||||
className="mx-3 my-2 py-1.5 bg-az-blue text-white rounded text-xs hover:brightness-110">
|
||||
+ {t('flights.create')}
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-az-border p-2">
|
||||
<label className="block text-[9px] text-az-muted uppercase tracking-wide mb-1">{t('flights.telemetry')}</label>
|
||||
<input type="date" className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-[10px] text-az-text" />
|
||||
</div>
|
||||
|
||||
{/* Flight list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredFlights.map(f => {
|
||||
const isActive = selectedFlight?.id === f.id
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
onClick={() => 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 && (
|
||||
<span style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 2, background: 'var(--accent-amber)' }} />
|
||||
)}
|
||||
<span style={{ color: 'var(--accent-amber)' }} className="truncate">
|
||||
{f.name}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto text-[10px]"
|
||||
style={{ color: 'var(--text-muted)', letterSpacing: '0.08em' }}
|
||||
>
|
||||
{new Date(f.createdDate).toLocaleDateString()}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete(f.id) }}
|
||||
className="opacity-0 group-hover:opacity-100 hover:text-accent-red text-text-muted text-[13px] leading-none shrink-0"
|
||||
aria-label="Delete flight"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Create section */}
|
||||
<div className="p-3 border-t border-border-hair">
|
||||
{creating ? (
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={e => 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 }}
|
||||
/>
|
||||
<button onClick={handleCreate} className="btn btn-primary shrink-0">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="btn btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<path d="M5 1 V9 M1 5 H9" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
{t('flights.v2.createNew')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Telemetry card */}
|
||||
<div className="m-3 mt-0 bracket panel p-3">
|
||||
<span className="br" />
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="micro" style={{ color: 'var(--accent-amber)' }}>// {t('flights.telemetry')}</span>
|
||||
</div>
|
||||
<label className="micro block mb-1">{t('flights.v2.date')}</label>
|
||||
<input type="date" className="inp inp-mono text-[12px]" style={{ colorScheme: 'dark' }} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => onActionModeChange(mode)}
|
||||
className={`flex-1 px-2.5 py-1 rounded border text-[11px] ${colorMap.border} ${colorMap.text} ${active ? colorMap.bg : colorMap.hover}`}
|
||||
>{label}</button>
|
||||
)
|
||||
}
|
||||
const [hoveredMode, setHoveredMode] = useState<ActionMode | null>(null)
|
||||
|
||||
return (
|
||||
<div className="p-2 space-y-2 text-xs overflow-y-auto flex-1">
|
||||
<div className="flex gap-1">
|
||||
{modeBtn('points', t('flights.planner.addPoints'), 'orange')}
|
||||
{modeBtn('workArea', t('flights.planner.workArea'), 'green')}
|
||||
{modeBtn('prohibitedArea', t('flights.planner.prohibitedArea'), 'red')}
|
||||
</div>
|
||||
<section className="p-4 space-y-5 flex-1 overflow-y-auto text-[12px]">
|
||||
|
||||
{/* Draw-mode selector */}
|
||||
<div>
|
||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.location')}</label>
|
||||
<input
|
||||
value={locationInput}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<div className="text-az-muted text-[9px] mt-0.5">
|
||||
{t('flights.planner.currentLocation')}: {currentPosition.lat.toFixed(6)}, {currentPosition.lng.toFixed(6)}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="micro" style={{ color: 'var(--accent-amber)' }}>// {t('flights.v2.drawMode')}</span>
|
||||
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>{t('flights.v2.clickToPlot')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{DRAW_MODES.map(({ mode, i18nKey, accent, icon }) => {
|
||||
const active = actionMode === mode
|
||||
const { color, tint } = DRAW_MODE_ACCENT[accent]
|
||||
return (
|
||||
<button key={mode} onClick={() => onActionModeChange(mode)} className="mono"
|
||||
onMouseEnter={() => setHoveredMode(mode)} onMouseLeave={() => setHoveredMode(null)}
|
||||
style={{
|
||||
minHeight: 32, padding: '0 8px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
border: `1px solid ${color}`, color, borderRadius: 2,
|
||||
fontSize: 10, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase',
|
||||
background: active ? tint : (hoveredMode === mode ? 'rgba(255,255,255,0.04)' : 'transparent'),
|
||||
boxShadow: active ? `inset 0 0 0 1px ${color}` : 'none',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<span style={{ flexShrink: 0, display: 'inline-flex' }}>{icon}</span>
|
||||
<span style={{ textAlign: 'center', lineHeight: 1.1 }}>{t(i18nKey)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.aircraft')}</label>
|
||||
<select className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text">
|
||||
{aircrafts.map(a => <option key={a.id} value={a.id}>{a.model}</option>)}
|
||||
</select>
|
||||
{/* Mission Config */}
|
||||
<header className="flex items-center justify-between">
|
||||
<h2 className="sect-head">{t('flights.v2.missionConfig')}</h2>
|
||||
</header>
|
||||
|
||||
<div className="bracket panel p-3 space-y-3">
|
||||
<div>
|
||||
<label className="micro block mb-1.5">{t('flights.v2.aircraft')}</label>
|
||||
<select className="inp">
|
||||
{aircrafts.map(a => <option key={a.id} value={a.id}>{a.model}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="micro block mb-1.5">{t('flights.v2.defaultHeight')}</label>
|
||||
<div className="relative">
|
||||
<input type="number" value={initialAltitude}
|
||||
onChange={e => onInitialAltitudeChange(Number(e.target.value))}
|
||||
className="inp inp-mono" style={{ paddingRight: 36 }} />
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 micro" style={{ color: 'var(--text-muted)' }}>M</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="micro block mb-1.5">{t('flights.v2.focalLength')}</label>
|
||||
<div className="relative">
|
||||
<input type="text" placeholder={t('flights.planner.cameraFovPlaceholder')} className="inp inp-mono" style={{ paddingRight: 40 }} />
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 micro" style={{ color: 'var(--text-muted)' }}>MM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="micro block mb-1.5">{t('flights.v2.commAddr')}</label>
|
||||
<input type="text" placeholder={t('flights.planner.commAddrPlaceholder')} className="inp inp-mono" />
|
||||
</div>
|
||||
<span className="br" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.initialAltitude')}</label>
|
||||
<input type="number" value={initialAltitude}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.cameraFov')}</label>
|
||||
<input type="text" placeholder={t('flights.planner.cameraFovPlaceholder')}
|
||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text outline-none focus:border-az-orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.commAddr')}</label>
|
||||
<input type="text" placeholder={t('flights.planner.commAddrPlaceholder')}
|
||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text outline-none focus:border-az-orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-az-muted block mb-1 text-[9px]">{t('flights.waypoints')}</label>
|
||||
{/* Waypoints */}
|
||||
<div className="bracket panel p-3">
|
||||
<header className="flex items-center justify-between mb-2.5">
|
||||
<span className="sect-head">{t('flights.waypoints')}</span>
|
||||
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>
|
||||
{String(points.length).padStart(2, '0')} {t('flights.v2.pts')}
|
||||
</span>
|
||||
</header>
|
||||
<WaypointList
|
||||
points={points}
|
||||
calculatedPointInfo={calculatedPointInfo}
|
||||
@@ -115,13 +127,32 @@ export default function FlightParamsPanel({
|
||||
onEdit={onEditPoint}
|
||||
onRemove={onRemovePoint}
|
||||
/>
|
||||
<span className="br" />
|
||||
</div>
|
||||
|
||||
{/* ── Existing controls (restyled, appended below mockup blocks) ── */}
|
||||
|
||||
<div>
|
||||
<label className="micro block mb-1.5">{t('flights.planner.location')}</label>
|
||||
<input
|
||||
value={locationInput}
|
||||
onChange={e => onLocationInputChange(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && onLocationSearch()}
|
||||
placeholder="47.242, 35.024"
|
||||
className="inp inp-mono"
|
||||
/>
|
||||
<div className="micro mt-1" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('flights.planner.currentLocation')}: {currentPosition.lat.toFixed(6)}, {currentPosition.lng.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{points.length > 1 && (
|
||||
<div className="bg-az-header rounded px-2 py-1 flex gap-2 text-[10px]">
|
||||
<span>{totalDistance}</span>
|
||||
<span>{totalTime}</span>
|
||||
<span style={{ color: batteryStatus.color }}>{batteryStatus.label}</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="pill pill-muted">{totalDistance}</span>
|
||||
<span className="pill pill-muted">{totalTime}</span>
|
||||
<span className="pill" style={{ color: batteryStatus.color }}>
|
||||
<span className="dot" />{batteryStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -129,22 +160,16 @@ export default function FlightParamsPanel({
|
||||
|
||||
<WindEffect wind={wind} onChange={onWindChange} />
|
||||
|
||||
<div className="flex gap-1">
|
||||
<button onClick={onSave} className="flex-1 px-2.5 py-1 rounded border border-az-green text-az-green text-[11px] hover:bg-az-green/10">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={onSave} className="btn btn-secondary justify-center" style={{ color: 'var(--accent-green)', borderColor: 'var(--accent-green)' }}>
|
||||
{t('flights.planner.save')}
|
||||
</button>
|
||||
<button onClick={onUpload} className="flex-1 px-2.5 py-1 rounded border border-az-blue text-az-blue text-[11px] hover:bg-az-blue/10">
|
||||
<button onClick={onUpload} className="btn btn-secondary justify-center" style={{ color: 'var(--accent-cyan)', borderColor: 'var(--accent-cyan)' }}>
|
||||
{t('flights.planner.upload')}
|
||||
</button>
|
||||
<button onClick={onEditJson} className="btn btn-ghost justify-center">{t('flights.planner.editAsJson')}</button>
|
||||
<button onClick={onExport} className="btn btn-ghost justify-center">{t('flights.planner.exportMapData')}</button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={onEditJson} className="flex-1 px-2.5 py-1 rounded border border-az-muted text-az-text text-[11px] hover:border-az-text hover:text-white">
|
||||
{t('flights.planner.editAsJson')}
|
||||
</button>
|
||||
<button onClick={onExport} className="flex-1 px-2.5 py-1 rounded border border-az-muted text-az-text text-[11px] hover:border-az-text hover:text-white">
|
||||
{t('flights.planner.exportMapData')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<OrthoPhoto[]>([])
|
||||
|
||||
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<Aircraft[]>(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<Waypoint[]>(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 ? (
|
||||
<div className="w-10 bg-az-panel border-r border-az-border flex flex-col items-center py-2 gap-2 shrink-0">
|
||||
<button onClick={() => 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">»</button>
|
||||
<button onClick={() => 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'}`}>●</button>
|
||||
<button onClick={() => 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'}`}>▣</button>
|
||||
<button onClick={() => 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'}`}>▣</button>
|
||||
<div className="shrink-0 flex flex-col items-center gap-2 border-r border-border-hair"
|
||||
style={{ width: 44, background: 'var(--surface-1)', padding: '10px 6px' }}>
|
||||
<button onClick={() => setCollapsed(false)} title={t('flights.v2.expandParams')}
|
||||
className="ibtn mono" style={{ width: 32, height: 32 }}>»</button>
|
||||
<span className="block" style={{ width: 24, height: 1, background: 'var(--border-hair)' }} />
|
||||
{DRAW_MODES.map(({ mode: m, i18nKey, accent, icon }) => {
|
||||
const active = actionMode === m
|
||||
const { color, tint } = DRAW_MODE_ACCENT[accent]
|
||||
return (
|
||||
<button key={m} onClick={() => 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}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-80 bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
||||
<div className="flex border-b border-az-border items-stretch">
|
||||
<div className="shrink-0 flex flex-col overflow-y-auto border-r border-border-hair"
|
||||
style={{ width: 290, background: 'var(--surface-1)' }}>
|
||||
<div className="flex items-stretch border-b border-border-hair" style={{ background: 'var(--surface-0)' }}>
|
||||
<button onClick={() => 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')}
|
||||
</button>
|
||||
<button onClick={() => 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')}
|
||||
</button>
|
||||
<button onClick={() => setCollapsed(true)} title="Collapse"
|
||||
className="px-2 text-az-muted hover:text-az-orange text-sm border-l border-az-border">«</button>
|
||||
<button onClick={() => setCollapsed(true)} title={t('flights.v2.collapse')}
|
||||
className="ibtn mono shrink-0 self-center mx-1" style={{ width: 26, height: 26 }}>«</button>
|
||||
</div>
|
||||
|
||||
{mode === 'params' && (
|
||||
@@ -282,24 +307,13 @@ export default function FlightsPage() {
|
||||
)}
|
||||
|
||||
{mode === 'gps' && (
|
||||
<div className="p-2 space-y-2 text-xs">
|
||||
<div>
|
||||
<label className="text-az-muted block mb-1">{t('flights.liveGps')}</label>
|
||||
{liveGps ? (
|
||||
<div className="bg-az-bg rounded p-1.5 space-y-0.5">
|
||||
<div className="text-az-text">Status: <span className="text-az-green">{liveGps.status}</span></div>
|
||||
<div className="text-az-text">Lat: {liveGps.lat.toFixed(6)}</div>
|
||||
<div className="text-az-text">Lon: {liveGps.lon.toFixed(6)}</div>
|
||||
<div className="text-az-text">Sats: {liveGps.satellites}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-az-muted">Waiting for GPS signal...</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setMode('params')} className="text-az-orange text-xs">
|
||||
← {t('flights.back')}
|
||||
</button>
|
||||
</div>
|
||||
<GpsDeniedPanel
|
||||
liveGps={liveGps}
|
||||
orthophotos={orthophotos}
|
||||
onAddOrthophotos={(photos) => setOrthophotos(prev => [...prev, ...photos])}
|
||||
onApplyCorrection={handleApplyCorrection}
|
||||
onBack={() => setMode('params')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -315,6 +329,8 @@ export default function FlightsPage() {
|
||||
onPolylineClick={handlePolylineClick}
|
||||
onPositionChange={setCurrentPosition}
|
||||
onMapMove={() => {}}
|
||||
liveGps={liveGps}
|
||||
flightLabel={selectedFlight?.name ?? '—'}
|
||||
/>
|
||||
|
||||
<AltitudeDialog
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { newGuid } from './flightPlanUtils'
|
||||
import type { OrthoPhoto } from './types'
|
||||
|
||||
interface LiveGps {
|
||||
lat: number
|
||||
lon: number
|
||||
satellites: number
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
liveGps: LiveGps | null
|
||||
orthophotos: OrthoPhoto[]
|
||||
onAddOrthophotos: (photos: OrthoPhoto[]) => 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 (
|
||||
<div className={`flex items-center justify-between py-1 ${className ?? ''}`}>
|
||||
<span className="micro">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="p-4 space-y-5 flex-1 overflow-y-auto">
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<h2 className="sect-head" style={{ color: 'var(--accent-red)', whiteSpace: 'nowrap' }}>{t('flights.v2.gpsDeniedActive')}</h2>
|
||||
<span className="pill pill-red" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
<span className="dot live" />{t('flights.v2.active')}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="bracket panel" style={{
|
||||
padding: 12,
|
||||
border: '2px solid var(--accent-red)',
|
||||
boxShadow: 'inset 0 0 0 1px rgba(255,71,86,0.12)',
|
||||
['--accent-amber' as string]: 'var(--accent-red)',
|
||||
} as React.CSSProperties}>
|
||||
<header className="flex items-center justify-between" style={{ marginBottom: 12 }}>
|
||||
<span className="sect-head" style={{ color: 'var(--accent-red)' }}>// {t('flights.v2.orthophotoUpload')}</span>
|
||||
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>
|
||||
{String(orthophotos.length).padStart(2, '0')} / 12
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{orthophotos.map((p, i) => (
|
||||
<div key={p.id} className="flex items-center gap-2.5 border border-border-hair"
|
||||
style={{ padding: '8px 10px', background: 'var(--surface-0)' }}>
|
||||
<span className="flex items-center justify-center shrink-0 mono"
|
||||
style={{ width: 24, height: 24, background: 'var(--accent-cyan)', color: '#0A0D10', fontSize: 10, fontWeight: 700 }}>
|
||||
P{i + 1}
|
||||
</span>
|
||||
<span className="mono text-[11px] flex-1 truncate" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
||||
<span className="mono text-[10px]" style={{ color: 'var(--text-secondary)' }}>
|
||||
{p.lat.toFixed(4)}, {p.lon.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={handleUpload}
|
||||
className="w-full mono flex items-center justify-center gap-2"
|
||||
style={{ marginTop: 10, padding: '8px 0', fontSize: 10, letterSpacing: '0.12em', textTransform: 'uppercase',
|
||||
border: '1px dashed var(--border-raised)', color: 'var(--text-secondary)', background: 'transparent', borderRadius: 2 }}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M5 1 V9 M1 5 H9" stroke="currentColor" strokeWidth="1.4" /></svg>
|
||||
{t('flights.v2.uploadPhotos')}
|
||||
</button>
|
||||
<span className="br" />
|
||||
</div>
|
||||
|
||||
{/* Live GPS readout */}
|
||||
<div className="bracket panel" style={{ padding: 12 }}>
|
||||
<header className="flex items-center justify-between gap-2" style={{ marginBottom: 10 }}>
|
||||
<span className="sect-head" style={{ whiteSpace: 'nowrap' }}>// {t('flights.v2.liveGps')}</span>
|
||||
<span className={`pill ${connected ? 'pill-green' : 'pill-muted'}`} style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
<span className="dot live" />{connected ? t('flights.v2.connected') : t('flights.v2.offline')}
|
||||
</span>
|
||||
</header>
|
||||
<div className="space-y-1.5 text-[12px]">
|
||||
<Row label={t('flights.v2.status')} className="border-b border-border-hair">
|
||||
<span className="mono" style={{ whiteSpace: 'nowrap', color: connected ? 'var(--accent-green)' : 'var(--text-secondary)' }}>
|
||||
{connected ? t('flights.v2.connectedStreaming') : t('flights.v2.offline')}
|
||||
</span>
|
||||
</Row>
|
||||
<Row label={t('flights.v2.latitude')} className="border-b border-border-hair">
|
||||
<span className="mono num">{(liveGps?.lat ?? 0).toFixed(5)}° N</span>
|
||||
</Row>
|
||||
<Row label={t('flights.v2.longitude')} className="border-b border-border-hair">
|
||||
<span className="mono num">{(liveGps?.lon ?? 0).toFixed(5)}° E</span>
|
||||
</Row>
|
||||
<Row label={t('flights.v2.satellites')} className="border-b border-border-hair">
|
||||
<span className="mono num" style={{ color: 'var(--accent-cyan)' }}>{liveGps?.satellites ?? 0} / 14</span>
|
||||
</Row>
|
||||
<Row label={t('flights.v2.drift')}>
|
||||
<span className="mono num" style={{ color: 'var(--accent-amber)' }}>±2.4 M</span>
|
||||
</Row>
|
||||
</div>
|
||||
<span className="br" />
|
||||
</div>
|
||||
|
||||
{/* GPS Correction */}
|
||||
<div className="bracket panel" style={{ padding: 12 }}>
|
||||
<header className="flex items-center justify-between" style={{ marginBottom: 10 }}>
|
||||
<span className="sect-head">// {t('flights.v2.gpsCorrection')}</span>
|
||||
</header>
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<label className="micro block mb-1.5">{t('flights.v2.waypointNum')}</label>
|
||||
<input value={wp} onChange={e => setWp(e.target.value)} type="number" className="inp inp-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="micro block mb-1.5">{t('flights.v2.correctedGps')}</label>
|
||||
<input value={coords} onChange={e => setCoords(e.target.value)} type="text" placeholder="48.86120, 2.36011" className="inp inp-mono" />
|
||||
</div>
|
||||
<button onClick={handleApply} className="btn btn-primary w-full justify-center">{t('flights.v2.applyCorrection')}</button>
|
||||
</div>
|
||||
<span className="br" />
|
||||
</div>
|
||||
|
||||
<button onClick={onBack} className="btn btn-ghost w-full justify-center">‹ {t('flights.v2.backToParams')}</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -23,30 +23,36 @@ export default function JsonEditorDialog({ open, jsonText, onClose, onSave }: Pr
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[2000]">
|
||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-[700px] max-h-[80vh] shadow-xl flex flex-col">
|
||||
<h3 className="text-white font-semibold mb-2">{t('flights.planner.editAsJson')}</h3>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[2000]" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
||||
<div
|
||||
className="bracket panel shadow-xl flex flex-col"
|
||||
style={{ background: 'var(--surface-1)', padding: '20px', width: '700px', maxHeight: '80vh' }}
|
||||
>
|
||||
<h3 className="sect-head mb-3">{t('flights.planner.editAsJson')}</h3>
|
||||
<textarea
|
||||
value={edited}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
rows={20}
|
||||
className={`flex-1 w-full bg-az-bg border rounded px-3 py-2 text-az-text text-xs font-mono outline-none resize-none ${
|
||||
valid ? 'border-az-border focus:border-az-orange' : 'border-az-red'
|
||||
}`}
|
||||
className="inp inp-mono flex-1 resize-none"
|
||||
style={{
|
||||
maxHeight: '60vh',
|
||||
borderColor: valid ? undefined : 'var(--accent-red)',
|
||||
boxShadow: valid ? undefined : '0 0 0 1px var(--accent-red)',
|
||||
}}
|
||||
/>
|
||||
<p className={`text-xs mt-1 ${valid ? 'text-az-muted' : 'text-az-red'}`}>
|
||||
<p className="text-[11px] mt-1.5" style={{ color: valid ? 'var(--text-secondary)' : 'var(--accent-red)' }}>
|
||||
{valid ? t('flights.planner.editJsonHint') : t('flights.planner.invalidJson')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button onClick={onClose}
|
||||
className="px-3 py-1 text-sm border border-az-border rounded hover:bg-az-bg text-az-text">
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={onClose} className="btn btn-ghost">
|
||||
{t('flights.planner.cancel')}
|
||||
</button>
|
||||
<button onClick={() => valid && onSave(edited)} disabled={!valid}
|
||||
className="px-3 py-1 text-sm bg-az-orange rounded hover:bg-orange-600 text-white disabled:opacity-40">
|
||||
<button onClick={() => valid && onSave(edited)} disabled={!valid} className="btn btn-primary">
|
||||
{t('flights.planner.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="br" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<L.Marker>(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({
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-xs space-y-1.5 min-w-[140px]">
|
||||
<div className="font-semibold">{t('flights.planner.point')} {index + 1}</div>
|
||||
<div>
|
||||
<label className="text-az-muted text-[10px]">{t('flights.planner.altitude')}</label>
|
||||
<input type="range" min={0} max={3000} value={point.altitude}
|
||||
onChange={e => onAltitudeChange(index, Number(e.target.value))}
|
||||
className="w-full accent-az-orange" />
|
||||
<span className="text-[10px] text-az-muted">{point.altitude}m</span>
|
||||
<div style={{ minWidth: 148, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div
|
||||
className="mono"
|
||||
style={{ color: 'var(--accent-amber)', fontSize: 12, fontWeight: 600 }}
|
||||
>
|
||||
{t('flights.planner.point')} {index + 1}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<label style={{ color: 'var(--text-secondary)', fontSize: 11 }}>
|
||||
{t('flights.planner.altitude')}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={3000}
|
||||
value={point.altitude}
|
||||
onChange={e => onAltitudeChange(index, Number(e.target.value))}
|
||||
className="w-full"
|
||||
style={{ accentColor: 'var(--accent-amber)' }}
|
||||
/>
|
||||
<span
|
||||
className="mono"
|
||||
style={{ color: 'var(--text-primary)', fontSize: 11 }}
|
||||
>
|
||||
{point.altitude}m
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{PURPOSES.map(p => (
|
||||
<label key={p.value} className="flex items-center gap-1 text-[10px] cursor-pointer">
|
||||
<input type="checkbox" checked={point.meta.includes(p.value)}
|
||||
onChange={() => toggleMeta(p.value)} className="accent-az-orange" />
|
||||
<label
|
||||
key={p.value}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', color: 'var(--text-primary)', fontSize: 12 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={point.meta.includes(p.value)}
|
||||
onChange={() => toggleMeta(p.value)}
|
||||
style={{ accentColor: 'var(--accent-amber)' }}
|
||||
/>
|
||||
{t(`flights.planner.${p.label}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => onRemove(point.id)}
|
||||
className="text-az-red text-[10px] hover:underline">
|
||||
<button
|
||||
onClick={() => onRemove(point.id)}
|
||||
style={{ color: 'var(--accent-red)', fontSize: 11, background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left', textDecoration: 'none' }}
|
||||
onMouseOver={e => (e.currentTarget.style.textDecoration = 'underline')}
|
||||
onMouseOut={e => (e.currentTarget.style.textDecoration = 'none')}
|
||||
>
|
||||
{t('flights.planner.removePoint')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -17,13 +17,13 @@ interface Props {
|
||||
export default function MiniMap({ pointPosition }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="absolute w-[240px] h-[180px] border border-az-border rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
|
||||
className="absolute w-[240px] h-[180px] border border-border-hair rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
|
||||
style={{ top: pointPosition.y, left: pointPosition.x }}
|
||||
>
|
||||
<MapContainer center={pointPosition.latlng} zoom={18} zoomControl={false}
|
||||
className="w-full h-full" attributionControl={false}>
|
||||
<TileLayer url={getTileUrl()} crossOrigin="use-credentials" />
|
||||
<CircleMarker center={pointPosition.latlng} radius={3} color="#fa5252" />
|
||||
<CircleMarker center={pointPosition.latlng} radius={3} color="#FF4756" />
|
||||
<UpdateCenter latlng={pointPosition.latlng} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: 'var(--accent-green)',
|
||||
transform: 'rotate(45deg)',
|
||||
flexShrink: 0,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (index === points.length - 1 && points.length > 1) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: 'var(--accent-red)',
|
||||
clipPath: 'polygon(30% 0,70% 0,100% 30%,100% 70%,70% 100%,30% 100%,0 70%,0 30%)',
|
||||
flexShrink: 0,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: 'transparent',
|
||||
border: '1.5px solid var(--accent-cyan)',
|
||||
flexShrink: 0,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="waypoints">
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-0.5">
|
||||
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-0">
|
||||
{points.map((point, index) => (
|
||||
<Draggable key={point.id} draggableId={point.id} index={index}>
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}
|
||||
className="flex items-center justify-between bg-az-bg rounded px-1.5 py-1 text-[10px] text-az-text group">
|
||||
<span>
|
||||
<span className="text-az-orange font-bold mr-1">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className="flex items-center gap-2.5 border-b border-border-hair mono group"
|
||||
style={{ height: 30, padding: '0 4px', ...provided.draggableProps.style }}
|
||||
>
|
||||
<span
|
||||
className="mono text-[11px]"
|
||||
style={{ color: 'var(--text-secondary)', width: 28, flexShrink: 0 }}
|
||||
>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
{renderMarker(index)}
|
||||
|
||||
<span className="text-[11px] text-text-primary truncate flex-1">
|
||||
{formatInfo(calculatedPointInfo[index], point.altitude)}
|
||||
</span>
|
||||
<span className="flex gap-1 opacity-0 group-hover:opacity-100">
|
||||
<button onClick={() => onEdit(point)} className="hover:text-az-orange">✎</button>
|
||||
<button onClick={() => onRemove(point.id)} className="hover:text-az-red">×</button>
|
||||
|
||||
<span className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => onEdit(point)}
|
||||
className="ibtn edit"
|
||||
style={{ width: 22, height: 22 }}
|
||||
title={t('flights.planner.edit')}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove(point.id)}
|
||||
className="ibtn danger"
|
||||
style={{ width: 22, height: 22 }}
|
||||
title={t('flights.planner.remove')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,19 +12,19 @@ export default function WindEffect({ wind, onChange }: Props) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.windDirection')}</label>
|
||||
<label className="micro block mb-0.5">{t('flights.planner.windDirection')}</label>
|
||||
<input type="number" min={0} max={360}
|
||||
value={wind.direction}
|
||||
onChange={e => onChange({ ...wind, direction: Number(e.target.value) })}
|
||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none focus:border-az-orange"
|
||||
className="inp inp-mono w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.windSpeed')}</label>
|
||||
<label className="micro block mb-0.5">{t('flights.planner.windSpeed')}</label>
|
||||
<input type="number" min={0}
|
||||
value={wind.speed}
|
||||
onChange={e => onChange({ ...wind, speed: Number(e.target.value) })}
|
||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none focus:border-az-orange"
|
||||
className="inp inp-mono w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<DrawAccent, { color: string; tint: string }> = {
|
||||
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: <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="1.6" fill="currentColor" /><circle cx="18" cy="6" r="1.6" fill="currentColor" /><circle cx="12" cy="14" r="1.6" fill="currentColor" /><circle cx="6" cy="20" r="1.6" fill="currentColor" /><circle cx="18" cy="20" r="1.6" fill="currentColor" /></svg>,
|
||||
},
|
||||
{
|
||||
mode: 'workArea', i18nKey: 'flights.v2.workArea', accent: 'green',
|
||||
icon: <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="4 7 12 3 20 7 20 17 12 21 4 17" /></svg>,
|
||||
},
|
||||
{
|
||||
mode: 'prohibitedArea', i18nKey: 'flights.v2.noGoZone', accent: 'red',
|
||||
icon: <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><line x1="5.6" y1="5.6" x2="18.4" y2="18.4" /></svg>,
|
||||
},
|
||||
]
|
||||
@@ -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: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="24" height="24" fill="${color}"><path d="M384 192c0 87.4-117 243-168.3 307.2a24 24 0 0 1-47.4 0C117 435 0 279.4 0 192 0 86 86 0 192 0s192 86 192 192z"/></svg>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 24],
|
||||
popupAnchor: [0, -24],
|
||||
html: `<div style="display:flex;align-items:center;justify-content:center;width:${size}px;height:${size}px;">${html}</div>`,
|
||||
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(
|
||||
`<div style="width:14px;height:14px;background:#3DDC84;border:1.5px solid #0A0D10;box-shadow:0 0 0 1px #3DDC84;transform:rotate(45deg);"></div>`,
|
||||
20,
|
||||
)
|
||||
export const wpMidIcon = glyphIcon(
|
||||
`<div style="width:12px;height:12px;background:#0A0D10;border:1.5px solid #36D6C5;"></div>`,
|
||||
16,
|
||||
)
|
||||
export const wpFinishIcon = glyphIcon(
|
||||
`<div style="width:16px;height:16px;background:#FF4756;clip-path:polygon(30% 0,70% 0,100% 30%,100% 70%,70% 100%,30% 100%,0 70%,0 30%);"></div>`,
|
||||
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: `<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 34 34">
|
||||
<circle cx="17" cy="17" r="5" fill="none" stroke="#FF9D3D" stroke-width="1.5">
|
||||
<animate attributeName="r" values="5;15" dur="1.6s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0.7;0" dur="1.6s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="17" cy="17" r="8" fill="none" stroke="#FF9D3D" stroke-width="1" opacity="0.45"/>
|
||||
<circle cx="17" cy="17" r="4" fill="#FF9D3D" stroke="#0A0D10" stroke-width="1"/>
|
||||
</svg>`,
|
||||
iconSize: [34, 34],
|
||||
iconAnchor: [17, 17],
|
||||
popupAnchor: [0, -17],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Вітер",
|
||||
|
||||
Reference in New Issue
Block a user