mirror of
https://github.com/azaion/ui.git
synced 2026-04-23 01:16:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 274800e508 |
@@ -1,4 +1,6 @@
|
|||||||
.idea
|
.idea
|
||||||
|
.claude
|
||||||
|
.superpowers
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|||||||
@@ -9,17 +9,26 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet-draw": "^1.0.4",
|
||||||
|
"leaflet-polylinedecorator": "^1.6.0",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-leaflet-draw": "^0.21.0",
|
||||||
"react-router-dom": "^7.4.0"
|
"react-router-dom": "^7.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.1",
|
"@tailwindcss/vite": "^4.1.1",
|
||||||
"@types/leaflet": "^1.9.17",
|
"@types/leaflet": "^1.9.17",
|
||||||
|
"@types/leaflet-draw": "^1.0.13",
|
||||||
|
"@types/leaflet-polylinedecorator": "^1.6.5",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Line } from 'react-chartjs-2'
|
||||||
|
import 'chart.js/auto'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { FlightPoint } from './types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
points: FlightPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AltitudeChart({ points }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (points.length === 0) return null
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: points.map((_, i) => i + 1),
|
||||||
|
datasets: [{
|
||||||
|
label: t('flights.planner.altitude'),
|
||||||
|
data: points.map(p => p.altitude),
|
||||||
|
borderColor: '#228be6',
|
||||||
|
backgroundColor: 'rgba(34,139,230,0.2)',
|
||||||
|
pointBackgroundColor: '#fd7e14',
|
||||||
|
pointBorderColor: '#1e1e1e',
|
||||||
|
pointBorderWidth: 1,
|
||||||
|
tension: 0.1,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
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' } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-16">
|
||||||
|
<Line data={data} options={options} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { COORDINATE_PRECISION, PURPOSES } from './types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
isEditMode?: boolean
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
altitude: number
|
||||||
|
meta: string[]
|
||||||
|
onLatitudeChange: (v: number) => void
|
||||||
|
onLongitudeChange: (v: number) => void
|
||||||
|
onAltitudeChange: (v: number) => void
|
||||||
|
onMetaChange: (v: string[]) => void
|
||||||
|
onSubmit: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AltitudeDialog({
|
||||||
|
open, isEditMode, latitude, longitude, altitude, meta,
|
||||||
|
onLatitudeChange, onLongitudeChange, onAltitudeChange, onMetaChange,
|
||||||
|
onSubmit, onClose,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const handleCoord = (value: string, setter: (v: number) => void) => {
|
||||||
|
const n = parseFloat(value)
|
||||||
|
if (!isNaN(n)) setter(parseFloat(n.toFixed(COORDINATE_PRECISION)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMeta = (value: string) => {
|
||||||
|
onMetaChange(meta.includes(value) ? meta.filter(m => m !== value) : [...meta, value])
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{isEditMode ? t('flights.planner.titleEdit') : t('flights.planner.titleAdd')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-az-muted text-xs mb-3">{t('flights.planner.description')}</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="text-az-muted block mb-0.5">{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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-az-muted block mb-0.5">{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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-az-muted block mb-0.5">{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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-az-muted block mb-1">{t('flights.planner.purpose')}</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{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)}
|
||||||
|
onChange={() => toggleMeta(p.value)}
|
||||||
|
className="rounded border-az-border bg-az-bg accent-az-orange" />
|
||||||
|
{t(`flights.planner.${p.label}`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
{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">
|
||||||
|
{isEditMode ? t('flights.planner.submitEdit') : t('flights.planner.submitAdd')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import { useMap } from 'react-leaflet'
|
||||||
|
import type { MapRectangle, ActionMode } from './types'
|
||||||
|
import { newGuid } from './flightPlanUtils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
color: string
|
||||||
|
actionMode: ActionMode
|
||||||
|
rectangles: MapRectangle[]
|
||||||
|
setRectangles: React.Dispatch<React.SetStateAction<MapRectangle[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DrawControl({ color, actionMode, setRectangles }: Props) {
|
||||||
|
const map = useMap()
|
||||||
|
const startRef = useRef<L.LatLng | null>(null)
|
||||||
|
const previewRef = useRef<L.Rectangle | null>(null)
|
||||||
|
const colorRef = useRef(color)
|
||||||
|
|
||||||
|
useEffect(() => { colorRef.current = color }, [color])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const drawing = actionMode === 'workArea' || actionMode === 'prohibitedArea'
|
||||||
|
if (!drawing) return
|
||||||
|
|
||||||
|
const container = map.getContainer()
|
||||||
|
const prevCursor = container.style.cursor
|
||||||
|
container.style.cursor = 'crosshair'
|
||||||
|
|
||||||
|
const onDown = (e: L.LeafletMouseEvent) => {
|
||||||
|
startRef.current = e.latlng
|
||||||
|
map.dragging.disable()
|
||||||
|
if (previewRef.current) { map.removeLayer(previewRef.current); previewRef.current = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (e: L.LeafletMouseEvent) => {
|
||||||
|
if (!startRef.current) return
|
||||||
|
const bounds = L.latLngBounds(startRef.current, e.latlng)
|
||||||
|
if (previewRef.current) {
|
||||||
|
previewRef.current.setBounds(bounds)
|
||||||
|
} else {
|
||||||
|
previewRef.current = L.rectangle(bounds, { color: colorRef.current, weight: 2, fillOpacity: 0.2 }).addTo(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = (e: L.LeafletMouseEvent) => {
|
||||||
|
if (!startRef.current) return
|
||||||
|
const start = startRef.current
|
||||||
|
const end = e.latlng
|
||||||
|
startRef.current = null
|
||||||
|
map.dragging.enable()
|
||||||
|
if (previewRef.current) { map.removeLayer(previewRef.current); previewRef.current = null }
|
||||||
|
|
||||||
|
if (Math.abs(start.lat - end.lat) < 1e-6 && Math.abs(start.lng - end.lng) < 1e-6) return
|
||||||
|
const bounds = L.latLngBounds(start, end)
|
||||||
|
const layer = L.rectangle(bounds, { color: colorRef.current, weight: 2, fillOpacity: 0.2 }).addTo(map)
|
||||||
|
setRectangles(prev => [...prev, { id: newGuid(), layer, color: colorRef.current as 'red' | 'green', bounds }])
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('mousedown', onDown)
|
||||||
|
map.on('mousemove', onMove)
|
||||||
|
map.on('mouseup', onUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('mousedown', onDown)
|
||||||
|
map.off('mousemove', onMove)
|
||||||
|
map.off('mouseup', onUp)
|
||||||
|
container.style.cursor = prevCursor
|
||||||
|
map.dragging.enable()
|
||||||
|
if (previewRef.current) { map.removeLayer(previewRef.current); previewRef.current = null }
|
||||||
|
startRef.current = null
|
||||||
|
}
|
||||||
|
}, [actionMode, map, setRectangles])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { Flight } from '../../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
flights: Flight[]
|
||||||
|
selectedFlight: Flight | null
|
||||||
|
onSelect: (flight: Flight) => void
|
||||||
|
onCreate: (name: string) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FlightListSidebar({ flights, selectedFlight, onSelect, onCreate, onDelete }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
const name = newName.trim()
|
||||||
|
if (!name) { setCreating(false); return }
|
||||||
|
onCreate(name)
|
||||||
|
setNewName('')
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setNewName('')
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,35 +1,183 @@
|
|||||||
import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet'
|
import { useRef, useEffect, useState } from 'react'
|
||||||
import type { Waypoint } from '../../types'
|
import { MapContainer, TileLayer, Marker, Popup, Polyline, Rectangle, useMap, useMapEvents } from 'react-leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
|
||||||
import L from '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 { defaultIcon } from './mapIcons'
|
||||||
|
import { TILE_URLS } from './types'
|
||||||
|
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
|
||||||
|
|
||||||
const icon = L.divIcon({ className: 'bg-az-orange rounded-full w-3 h-3 border border-white', iconSize: [12, 12] })
|
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: '#228be6', 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' } }) }],
|
||||||
|
}).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
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
waypoints: Waypoint[]
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FlightMap({ waypoints }: Props) {
|
export default function FlightMap({
|
||||||
const center: [number, number] = waypoints.length > 0
|
points, currentPosition, rectangles, setRectangles,
|
||||||
? [waypoints[0].latitude, waypoints[0].longitude]
|
rectangleColor, actionMode, onAddPoint, onUpdatePoint, onRemovePoint,
|
||||||
: [50.45, 30.52]
|
onAltitudeChange, onMetaChange, onPolylineClick, onPositionChange, onMapMove,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [mapType, setMapType] = useState<'classic' | 'satellite'>('satellite')
|
||||||
|
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
|
||||||
|
const [draggablePoints, setDraggablePoints] = useState(points)
|
||||||
|
const polylineClickRef = useRef(false)
|
||||||
|
|
||||||
const positions = waypoints
|
useEffect(() => { setDraggablePoints(points) }, [points])
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
.map(w => [w.latitude, w.longitude] as [number, number])
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer center={center} zoom={13} className="h-full w-full" key={center.join(',')}>
|
<div className="flex-1 relative" ref={containerRef}>
|
||||||
<TileLayer
|
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
<ClickHandler />
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
<TileLayer
|
||||||
/>
|
url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite}
|
||||||
{waypoints.map(wp => (
|
attribution={mapType === 'classic' ? '© <a href="https://www.openstreetmap.org/copyright">OSM</a>' : 'Satellite'}
|
||||||
<Marker key={wp.id} position={[wp.latitude, wp.longitude]} icon={icon}>
|
/>
|
||||||
<Popup>{wp.name}</Popup>
|
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
|
||||||
</Marker>
|
<SetView center={currentPosition} />
|
||||||
))}
|
|
||||||
{positions.length > 1 && <Polyline positions={positions} color="#fd7e14" weight={2} />}
|
{movingPoint && <MiniMap pointPosition={movingPoint} mapType={mapType} />}
|
||||||
</MapContainer>
|
|
||||||
|
{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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{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
|
||||||
|
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>
|
||||||
|
|
||||||
|
{(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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={() => setMapType(m => m === 'classic' ? 'satellite' : 'classic')}
|
||||||
|
className={`absolute top-2 right-2 z-[400] px-2 py-1 text-xs rounded border ${
|
||||||
|
mapType === 'satellite' ? 'bg-az-panel border-az-orange text-white' : 'bg-az-panel border-az-border text-az-text'
|
||||||
|
}`}>
|
||||||
|
{t('flights.planner.satellite')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import WaypointList from './WaypointList'
|
||||||
|
import AltitudeChart from './AltitudeChart'
|
||||||
|
import WindEffect from './WindEffect'
|
||||||
|
import type { FlightPoint, CalculatedPointInfo, ActionMode, WindParams } from './types'
|
||||||
|
import type { Aircraft } from '../../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
points: FlightPoint[]
|
||||||
|
calculatedPointInfo: CalculatedPointInfo[]
|
||||||
|
aircrafts: Aircraft[]
|
||||||
|
initialAltitude: number
|
||||||
|
actionMode: ActionMode
|
||||||
|
wind: WindParams
|
||||||
|
locationInput: string
|
||||||
|
currentPosition: { lat: number; lng: number }
|
||||||
|
totalDistance: string
|
||||||
|
totalTime: string
|
||||||
|
batteryStatus: { label: string; color: string }
|
||||||
|
onInitialAltitudeChange: (v: number) => void
|
||||||
|
onActionModeChange: (mode: ActionMode) => void
|
||||||
|
onWindChange: (w: WindParams) => void
|
||||||
|
onLocationInputChange: (v: string) => void
|
||||||
|
onLocationSearch: () => void
|
||||||
|
onReorderPoints: (points: FlightPoint[]) => void
|
||||||
|
onEditPoint: (point: FlightPoint) => void
|
||||||
|
onRemovePoint: (id: string) => void
|
||||||
|
onSave: () => void
|
||||||
|
onUpload: () => void
|
||||||
|
onEditJson: () => void
|
||||||
|
onExport: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FlightParamsPanel({
|
||||||
|
points, calculatedPointInfo, aircrafts, initialAltitude, actionMode, wind,
|
||||||
|
locationInput, currentPosition, totalDistance, totalTime, batteryStatus,
|
||||||
|
onInitialAltitudeChange, onActionModeChange, onWindChange,
|
||||||
|
onLocationInputChange, onLocationSearch, onReorderPoints, onEditPoint, onRemovePoint,
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
<WaypointList
|
||||||
|
points={points}
|
||||||
|
calculatedPointInfo={calculatedPointInfo}
|
||||||
|
onReorder={onReorderPoints}
|
||||||
|
onEdit={onEditPoint}
|
||||||
|
onRemove={onRemovePoint}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AltitudeChart points={points} />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{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">
|
||||||
|
{t('flights.planner.upload')}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,46 +1,82 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import L from 'leaflet'
|
||||||
import { useFlight } from '../../components/FlightContext'
|
import { useFlight } from '../../components/FlightContext'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import { createSSE } from '../../api/sse'
|
import { createSSE } from '../../api/sse'
|
||||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||||
import type { Flight, Waypoint, Aircraft } from '../../types'
|
import FlightListSidebar from './FlightListSidebar'
|
||||||
|
import FlightParamsPanel from './FlightParamsPanel'
|
||||||
import FlightMap from './FlightMap'
|
import FlightMap from './FlightMap'
|
||||||
|
import AltitudeDialog from './AltitudeDialog'
|
||||||
|
import JsonEditorDialog from './JsonEditorDialog'
|
||||||
|
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'
|
||||||
|
|
||||||
export default function FlightsPage() {
|
export default function FlightsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { flights, selectedFlight, selectFlight, refreshFlights } = useFlight()
|
const { flights, selectedFlight, selectFlight, refreshFlights } = useFlight()
|
||||||
|
|
||||||
const [mode, setMode] = useState<'params' | 'gps'>('params')
|
const [mode, setMode] = useState<'params' | 'gps'>('params')
|
||||||
const [waypoints, setWaypoints] = useState<Waypoint[]>([])
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
||||||
const [liveGps, setLiveGps] = useState<{ lat: number; lon: number; satellites: number; status: string } | null>(null)
|
const [liveGps, setLiveGps] = useState<{ lat: number; lon: number; satellites: number; status: string } | null>(null)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
|
||||||
const [newName, setNewName] = useState('')
|
const [aircraft, setAircraft] = useState<AircraftParams | null>(null)
|
||||||
const leftPanel = useResizablePanel(200, 150, 350)
|
const [points, setPoints] = useState<FlightPoint[]>([])
|
||||||
|
const [calculatedPointInfo, setCalculatedPointInfo] = useState<CalculatedPointInfo[]>([])
|
||||||
|
const [rectangles, setRectangles] = useState<MapRectangle[]>([])
|
||||||
|
const [actionMode, setActionMode] = useState<ActionMode>('points')
|
||||||
|
const [wind, setWind] = useState<WindParams>({ direction: 0, speed: 0 })
|
||||||
|
const [initialAltitude, setInitialAltitude] = useState(1000)
|
||||||
|
const [currentPosition, setCurrentPosition] = useState<{ lat: number; lng: number }>({ lat: 47.242, lng: 35.024 })
|
||||||
|
const [locationInput, setLocationInput] = useState('')
|
||||||
|
|
||||||
|
const [altDialog, setAltDialog] = useState<{ open: boolean; point: FlightPoint | null; isEdit: boolean }>({ open: false, point: null, isEdit: false })
|
||||||
|
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||||
|
setAircraft(getMockAircraftParams())
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => setCurrentPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFlight) { setWaypoints([]); return }
|
if (!selectedFlight) { setPoints([]); return }
|
||||||
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).then(setWaypoints).catch(() => {})
|
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
|
||||||
|
.then(wps => {
|
||||||
|
setPoints(wps.sort((a, b) => a.order - b.order).map(wp => ({
|
||||||
|
id: wp.id,
|
||||||
|
position: { lat: wp.latitude, lng: wp.longitude },
|
||||||
|
altitude: 300,
|
||||||
|
meta: [PURPOSES[0].value, PURPOSES[1].value],
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}, [selectedFlight])
|
}, [selectedFlight])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFlight || mode !== 'gps') return
|
if (!selectedFlight || mode !== 'gps') return
|
||||||
return createSSE(`/api/flights/${selectedFlight.id}/live-gps`, (data: any) => setLiveGps(data))
|
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(`/api/flights/${selectedFlight.id}/live-gps`, (data) => setLiveGps(data))
|
||||||
}, [selectedFlight, mode])
|
}, [selectedFlight, mode])
|
||||||
|
|
||||||
const handleCreate = async () => {
|
useEffect(() => {
|
||||||
if (!newName.trim()) return
|
if (!aircraft || points.length < 2) { setCalculatedPointInfo([{ bat: 100, time: 0 }]); return }
|
||||||
await api.post('/api/flights', { name: newName.trim() })
|
calculateAllPoints(points, aircraft, initialAltitude).then(setCalculatedPointInfo)
|
||||||
setNewName('')
|
}, [points, aircraft, initialAltitude])
|
||||||
|
|
||||||
|
const handleCreateFlight = async (name: string) => {
|
||||||
|
await api.post('/api/flights', { name })
|
||||||
refreshFlights()
|
refreshFlights()
|
||||||
}
|
}
|
||||||
|
const handleDeleteFlight = async () => {
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deleteId) return
|
if (!deleteId) return
|
||||||
await api.delete(`/api/flights/${deleteId}`)
|
await api.delete(`/api/flights/${deleteId}`)
|
||||||
if (selectedFlight?.id === deleteId) selectFlight(null)
|
if (selectedFlight?.id === deleteId) selectFlight(null)
|
||||||
@@ -48,105 +84,203 @@ export default function FlightsPage() {
|
|||||||
refreshFlights()
|
refreshFlights()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddWaypoint = async () => {
|
const addPoint = useCallback((lat: number, lng: number) => {
|
||||||
if (!selectedFlight) return
|
const pt: FlightPoint = { id: newGuid(), position: { lat, lng }, altitude: initialAltitude, meta: [PURPOSES[0].value, PURPOSES[1].value] }
|
||||||
await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
|
setPoints(prev => [...prev, pt])
|
||||||
name: `Point ${waypoints.length}`,
|
}, [initialAltitude])
|
||||||
latitude: 50.45, longitude: 30.52, order: waypoints.length,
|
|
||||||
|
const updatePointPosition = useCallback((index: number, pos: { lat: number; lng: number }) => {
|
||||||
|
setPoints(prev => prev.map((p, i) => i === index ? { ...p, position: pos } : p))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removePoint = useCallback((id: string) => {
|
||||||
|
setPoints(prev => prev.filter(p => p.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePolylineClick = useCallback((e: L.LeafletMouseEvent) => {
|
||||||
|
const click = e.latlng
|
||||||
|
let closestIdx = -1, minDist = Infinity
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
if (i < points.length - 1) {
|
||||||
|
const dist = L.LineUtil.pointToSegmentDistance(
|
||||||
|
L.point(click.lng, click.lat),
|
||||||
|
L.point(p.position.lng, p.position.lat),
|
||||||
|
L.point(points[i + 1].position.lng, points[i + 1].position.lat),
|
||||||
|
)
|
||||||
|
if (dist < minDist) { minDist = dist; closestIdx = i }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const wps = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
|
if (closestIdx !== -1) {
|
||||||
setWaypoints(wps)
|
const alt = (points[closestIdx].altitude + points[closestIdx + 1].altitude) / 2
|
||||||
|
const pt: FlightPoint = { id: newGuid(), position: { lat: click.lat, lng: click.lng }, altitude: alt, meta: [PURPOSES[0].value, PURPOSES[1].value] }
|
||||||
|
setPoints(prev => { const u = [...prev]; u.splice(closestIdx + 1, 0, pt); return u })
|
||||||
|
}
|
||||||
|
}, [points])
|
||||||
|
|
||||||
|
const openEditDialog = (point: FlightPoint) => {
|
||||||
|
setAltDialog({ open: true, point: { ...point }, isEdit: true })
|
||||||
|
}
|
||||||
|
const handleAltSubmit = () => {
|
||||||
|
if (!altDialog.point) return
|
||||||
|
if (altDialog.isEdit) {
|
||||||
|
setPoints(prev => prev.map(p => p.id === altDialog.point!.id ? altDialog.point! : p))
|
||||||
|
} else {
|
||||||
|
setPoints(prev => [...prev, altDialog.point!])
|
||||||
|
}
|
||||||
|
setAltDialog({ open: false, point: null, isEdit: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteWaypoint = async (wpId: string) => {
|
const handleEditJson = () => {
|
||||||
if (!selectedFlight) return
|
const data = {
|
||||||
await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wpId}`)
|
operational_height: { currentAltitude: initialAltitude },
|
||||||
setWaypoints(prev => prev.filter(w => w.id !== wpId))
|
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) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${selectedFlight?.name ?? 'flight-plan'}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = 'application/json'
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const text = await file.text()
|
||||||
|
handleJsonSave(text)
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJsonSave = (json: string) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(json)
|
||||||
|
if (data.action_points) {
|
||||||
|
setPoints(data.action_points.map((ap: Record<string, unknown>) => ({
|
||||||
|
id: newGuid(),
|
||||||
|
position: {
|
||||||
|
lat: (ap.point as Record<string, number>)?.lat ?? (ap.lat as number),
|
||||||
|
lng: (ap.point as Record<string, number>)?.lon ?? (ap.lon as number),
|
||||||
|
},
|
||||||
|
altitude: (ap.height as number) || 300,
|
||||||
|
meta: (ap.action_specific as Record<string, string[]>)?.targets || [PURPOSES[0].value, PURPOSES[1].value],
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
if (data.geofences?.polygons) {
|
||||||
|
setRectangles(data.geofences.polygons.map((p: { southEast: { lat: number; lon: number }; northWest: { lat: number; lon: number }; fence_type?: string }) => ({
|
||||||
|
id: newGuid(),
|
||||||
|
bounds: L.latLngBounds([p.southEast.lat, p.northWest.lon], [p.northWest.lat, p.southEast.lon]),
|
||||||
|
color: p.fence_type === 'EXCLUSION' ? 'red' as const : 'green' as const,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
if (data.operational_height?.currentAltitude) setInitialAltitude(data.operational_height.currentAltitude)
|
||||||
|
setJsonDialog({ open: false, text: '' })
|
||||||
|
} catch { alert(t('flights.planner.invalidJson')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedFlight) return
|
||||||
|
const existing = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).catch(() => [] as Waypoint[])
|
||||||
|
for (const wp of existing) {
|
||||||
|
await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wp.id}`).catch(() => {})
|
||||||
|
}
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
|
||||||
|
name: `Point ${i + 1}`,
|
||||||
|
latitude: points[i].position.lat,
|
||||||
|
longitude: points[i].position.lng,
|
||||||
|
order: i,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLocationSearch = () => {
|
||||||
|
const coords = parseCoordinates(locationInput)
|
||||||
|
if (coords) setCurrentPosition(coords)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastInfo = calculatedPointInfo[calculatedPointInfo.length - 1]
|
||||||
|
const batteryStatus = !lastInfo ? { label: '', color: '#6c757d' }
|
||||||
|
: lastInfo.bat > 12 ? { label: t('flights.planner.statusGood'), color: '#40c057' }
|
||||||
|
: lastInfo.bat > 5 ? { label: t('flights.planner.statusCaution'), color: '#FFFF00' }
|
||||||
|
: { label: t('flights.planner.statusLow'), color: '#fa5252' }
|
||||||
|
|
||||||
|
const totalDist = aircraft && points.length > 1
|
||||||
|
? points.reduce((acc, p, i) => i === 0 ? 0 : acc + calculateDistance(points[i - 1], p, aircraft.type, initialAltitude, aircraft.downang, aircraft.upang), 0).toFixed(1) + t('flights.planner.km')
|
||||||
|
: ''
|
||||||
|
const totalTimeStr = lastInfo && lastInfo.time > 0
|
||||||
|
? `${Math.floor(lastInfo.time) >= 1 ? Math.floor(lastInfo.time) + t('flights.planner.hour') : ''}${Math.floor((lastInfo.time - Math.floor(lastInfo.time)) * 60)}${t('flights.planner.minutes')}`
|
||||||
|
: ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* Flight list sidebar */}
|
<FlightListSidebar
|
||||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
flights={flights}
|
||||||
<div className="p-2 border-b border-az-border">
|
selectedFlight={selectedFlight}
|
||||||
<div className="flex gap-1">
|
onSelect={selectFlight}
|
||||||
<input
|
onCreate={handleCreateFlight}
|
||||||
value={newName}
|
onDelete={(id) => setDeleteId(id)}
|
||||||
onChange={e => setNewName(e.target.value)}
|
/>
|
||||||
onKeyDown={e => e.key === 'Enter' && handleCreate()}
|
|
||||||
placeholder={t('flights.create')}
|
|
||||||
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
|
||||||
/>
|
|
||||||
<button onClick={handleCreate} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{flights.map(f => (
|
|
||||||
<div
|
|
||||||
key={f.id}
|
|
||||||
onClick={() => selectFlight(f)}
|
|
||||||
className={`px-2 py-1.5 cursor-pointer border-b border-az-border text-sm ${
|
|
||||||
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(); setDeleteId(f.id) }} className="text-az-muted hover:text-az-red text-xs">×</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-az-muted">{new Date(f.createdDate).toLocaleDateString()}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resize handle */}
|
{collapsed ? (
|
||||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
<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"
|
||||||
{/* Left params panel */}
|
className="w-8 h-8 rounded border border-az-border text-az-text hover:border-az-orange hover:text-az-orange text-sm">»</button>
|
||||||
{selectedFlight && (
|
<button onClick={() => setActionMode('points')} title={t('flights.planner.addPoints')}
|
||||||
<div className="w-64 bg-az-panel border-r border-az-border flex flex-col shrink-0 overflow-y-auto">
|
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>
|
||||||
<div className="flex border-b border-az-border">
|
<button onClick={() => setActionMode('workArea')} title={t('flights.planner.workArea')}
|
||||||
<button
|
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>
|
||||||
onClick={() => setMode('params')}
|
<button onClick={() => setActionMode('prohibitedArea')} title={t('flights.planner.prohibitedArea')}
|
||||||
className={`flex-1 py-1.5 text-xs ${mode === 'params' ? 'bg-az-bg text-white' : 'text-az-muted'}`}
|
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>
|
||||||
|
) : (
|
||||||
|
<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">
|
||||||
|
<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')}
|
{t('flights.params')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => setMode('gps')}
|
||||||
onClick={() => setMode('gps')}
|
className={`flex-1 py-1.5 text-[10px] ${mode === 'gps' ? 'bg-az-bg text-white' : 'text-az-muted'}`}>
|
||||||
className={`flex-1 py-1.5 text-xs ${mode === 'gps' ? 'bg-az-bg text-white' : 'text-az-muted'}`}
|
|
||||||
>
|
|
||||||
{t('flights.gpsDenied')}
|
{t('flights.gpsDenied')}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'params' && (
|
{mode === 'params' && (
|
||||||
<div className="p-2 space-y-2 text-xs">
|
<FlightParamsPanel
|
||||||
<div>
|
points={points} calculatedPointInfo={calculatedPointInfo} aircrafts={aircrafts}
|
||||||
<label className="text-az-muted block mb-0.5">{t('flights.aircraft')}</label>
|
initialAltitude={initialAltitude} actionMode={actionMode}
|
||||||
<select className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text">
|
wind={wind} locationInput={locationInput} currentPosition={currentPosition}
|
||||||
{aircrafts.map(a => <option key={a.id} value={a.id}>{a.model}</option>)}
|
totalDistance={totalDist} totalTime={totalTimeStr} batteryStatus={batteryStatus}
|
||||||
</select>
|
onInitialAltitudeChange={setInitialAltitude} onActionModeChange={setActionMode}
|
||||||
</div>
|
onWindChange={setWind} onLocationInputChange={setLocationInput}
|
||||||
<div>
|
onLocationSearch={handleLocationSearch}
|
||||||
<label className="text-az-muted block mb-0.5">{t('flights.height')}</label>
|
onReorderPoints={setPoints} onEditPoint={openEditDialog} onRemovePoint={removePoint}
|
||||||
<input type="number" className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" defaultValue={100} />
|
onSave={handleSave} onUpload={handleImport} onEditJson={handleEditJson} onExport={handleExport}
|
||||||
</div>
|
/>
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-1">
|
|
||||||
<label className="text-az-muted">{t('flights.waypoints')}</label>
|
|
||||||
<button onClick={handleAddWaypoint} className="text-az-orange text-xs">+ Add</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{waypoints.map(wp => (
|
|
||||||
<div key={wp.id} className="flex items-center justify-between bg-az-bg rounded px-1.5 py-0.5">
|
|
||||||
<span className="text-az-text">{wp.name}</span>
|
|
||||||
<button onClick={() => handleDeleteWaypoint(wp.id)} className="text-az-muted hover:text-az-red">×</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'gps' && (
|
{mode === 'gps' && (
|
||||||
@@ -172,16 +306,46 @@ export default function FlightsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Map view */}
|
<FlightMap
|
||||||
<div className="flex-1 relative">
|
points={points} calculatedPointInfo={calculatedPointInfo}
|
||||||
<FlightMap waypoints={waypoints} />
|
currentPosition={currentPosition} rectangles={rectangles} setRectangles={setRectangles}
|
||||||
</div>
|
rectangleColor={actionMode === 'workArea' ? 'green' : 'red'}
|
||||||
|
actionMode={actionMode}
|
||||||
|
onAddPoint={addPoint} onUpdatePoint={updatePointPosition} onRemovePoint={removePoint}
|
||||||
|
onAltitudeChange={(i, alt) => setPoints(prev => prev.map((p, idx) => idx === i ? { ...p, altitude: alt } : p))}
|
||||||
|
onMetaChange={(i, meta) => setPoints(prev => prev.map((p, idx) => idx === i ? { ...p, meta } : p))}
|
||||||
|
onPolylineClick={handlePolylineClick}
|
||||||
|
onPositionChange={setCurrentPosition}
|
||||||
|
onMapMove={() => {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AltitudeDialog
|
||||||
|
open={altDialog.open}
|
||||||
|
isEditMode={altDialog.isEdit}
|
||||||
|
latitude={altDialog.point?.position.lat ?? 0}
|
||||||
|
longitude={altDialog.point?.position.lng ?? 0}
|
||||||
|
altitude={altDialog.point?.altitude ?? 300}
|
||||||
|
meta={altDialog.point?.meta ?? []}
|
||||||
|
onLatitudeChange={v => setAltDialog(prev => ({ ...prev, point: prev.point ? { ...prev.point, position: { ...prev.point.position, lat: v } } : null }))}
|
||||||
|
onLongitudeChange={v => setAltDialog(prev => ({ ...prev, point: prev.point ? { ...prev.point, position: { ...prev.point.position, lng: v } } : null }))}
|
||||||
|
onAltitudeChange={v => setAltDialog(prev => ({ ...prev, point: prev.point ? { ...prev.point, altitude: v } : null }))}
|
||||||
|
onMetaChange={v => setAltDialog(prev => ({ ...prev, point: prev.point ? { ...prev.point, meta: v } : null }))}
|
||||||
|
onSubmit={handleAltSubmit}
|
||||||
|
onClose={() => setAltDialog({ open: false, point: null, isEdit: false })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<JsonEditorDialog
|
||||||
|
open={jsonDialog.open}
|
||||||
|
jsonText={jsonDialog.text}
|
||||||
|
onClose={() => setJsonDialog({ open: false, text: '' })}
|
||||||
|
onSave={handleJsonSave}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteId}
|
open={!!deleteId}
|
||||||
title={t('common.delete')}
|
title={t('common.delete')}
|
||||||
message="Delete this flight and all its data?"
|
message="Delete this flight and all its data?"
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDeleteFlight}
|
||||||
onCancel={() => setDeleteId(null)}
|
onCancel={() => setDeleteId(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
jsonText: string
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (json: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JsonEditorDialog({ open, jsonText, onClose, onSave }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [edited, setEdited] = useState(jsonText)
|
||||||
|
const [valid, setValid] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => { setEdited(jsonText) }, [jsonText])
|
||||||
|
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
setEdited(value)
|
||||||
|
try { JSON.parse(value); setValid(true) } catch { setValid(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<p className={`text-xs mt-1 ${valid ? 'text-az-muted' : 'text-az-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">
|
||||||
|
{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">
|
||||||
|
{t('flights.planner.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
|
import { Marker, Popup } from 'react-leaflet'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { pointIconGreen, pointIconBlue, pointIconRed } from './mapIcons'
|
||||||
|
import { PURPOSES } from './types'
|
||||||
|
import type { FlightPoint, MovingPointInfo } from './types'
|
||||||
|
import type L from 'leaflet'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
point: FlightPoint
|
||||||
|
points: FlightPoint[]
|
||||||
|
index: number
|
||||||
|
mapElement: HTMLElement | null
|
||||||
|
onDrag: (index: number, position: { lat: number; lng: number }) => void
|
||||||
|
onDragEnd: (index: number, position: { lat: number; lng: number }) => void
|
||||||
|
onAltitudeChange: (index: number, altitude: number) => void
|
||||||
|
onMetaChange: (index: number, meta: string[]) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
onMoving: (info: MovingPointInfo | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapPoint({
|
||||||
|
point, points, index, mapElement,
|
||||||
|
onDrag, onDragEnd, onAltitudeChange, onMetaChange, onRemove, onMoving,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const markerRef = useRef<L.Marker>(null)
|
||||||
|
|
||||||
|
const icon = index === 0 ? pointIconGreen : index === points.length - 1 ? pointIconRed : pointIconBlue
|
||||||
|
|
||||||
|
const handleMove = (e: L.LeafletEvent) => {
|
||||||
|
const marker = markerRef.current
|
||||||
|
if (!marker || !mapElement) return
|
||||||
|
const markerEl = (marker as unknown as { _icon: HTMLElement })._icon
|
||||||
|
if (!markerEl) return
|
||||||
|
const mapRect = mapElement.getBoundingClientRect()
|
||||||
|
const mRect = markerEl.getBoundingClientRect()
|
||||||
|
const dx = mRect.left - mapRect.left + mRect.width > mapRect.width / 2 ? -150 : 200
|
||||||
|
const dy = mRect.top + mRect.height > mapRect.height / 2 ? -150 : 150
|
||||||
|
onMoving({ x: mRect.left - mapRect.left + dx, y: mRect.top - mapRect.top + dy, latlng: (e.target as L.Marker).getLatLng() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMeta = (value: string) => {
|
||||||
|
const newMeta = point.meta.includes(value) ? point.meta.filter(m => m !== value) : [...point.meta, value]
|
||||||
|
onMetaChange(index, newMeta)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
position={point.position}
|
||||||
|
icon={icon}
|
||||||
|
draggable
|
||||||
|
ref={markerRef}
|
||||||
|
eventHandlers={{
|
||||||
|
drag: (e) => onDrag(index, (e.target as L.Marker).getLatLng()),
|
||||||
|
dragend: (e) => { onDragEnd(index, (e.target as L.Marker).getLatLng()); onMoving(null) },
|
||||||
|
move: handleMove,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{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" />
|
||||||
|
{t(`flights.planner.${p.label}`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => onRemove(point.id)}
|
||||||
|
className="text-az-red text-[10px] hover:underline">
|
||||||
|
{t('flights.planner.removePoint')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import type L from 'leaflet'
|
||||||
|
import { TILE_URLS } from './types'
|
||||||
|
import type { MovingPointInfo } from './types'
|
||||||
|
|
||||||
|
function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
|
||||||
|
const map = useMap()
|
||||||
|
useEffect(() => { map.setView(latlng) }, [latlng, map])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pointPosition: MovingPointInfo
|
||||||
|
mapType: 'classic' | 'satellite'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MiniMap({ pointPosition, mapType }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute w-[240px] h-[180px] border border-az-border 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={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite} />
|
||||||
|
<CircleMarker center={pointPosition.latlng} radius={3} color="#fa5252" />
|
||||||
|
<UpdateCenter latlng={pointPosition.latlng} />
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { FlightPoint, CalculatedPointInfo } from './types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
points: FlightPoint[]
|
||||||
|
calculatedPointInfo: CalculatedPointInfo[]
|
||||||
|
onReorder: (points: FlightPoint[]) => void
|
||||||
|
onEdit: (point: FlightPoint) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WaypointList({ points, calculatedPointInfo, onReorder, onEdit, onRemove }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleDragEnd = (result: DropResult) => {
|
||||||
|
if (!result.destination) return
|
||||||
|
const items = Array.from(points)
|
||||||
|
const [moved] = items.splice(result.source.index, 1)
|
||||||
|
items.splice(result.destination.index, 0, moved)
|
||||||
|
onReorder(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatInfo = (info: CalculatedPointInfo | undefined, alt: number) => {
|
||||||
|
if (!info) return `${alt}${t('flights.planner.metres')}`
|
||||||
|
const hours = Math.floor(info.time)
|
||||||
|
const mins = Math.floor((info.time - hours) * 60)
|
||||||
|
const timeStr = hours >= 1 ? `${hours}${t('flights.planner.hour')}${mins}${t('flights.planner.minutes')}` : `${mins}${t('flights.planner.minutes')}`
|
||||||
|
return `${alt}${t('flights.planner.metres')} ${Math.floor(info.bat)}%${t('flights.planner.battery')} ${timeStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<Droppable droppableId="waypoints">
|
||||||
|
{(provided) => (
|
||||||
|
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-0.5">
|
||||||
|
{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>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { WindParams } from './types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
wind: WindParams
|
||||||
|
onChange: (wind: WindParams) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WindEffect({ wind, onChange }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-az-muted block mb-0.5 text-[9px]">{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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { FlightPoint, CalculatedPointInfo, AircraftParams } from './types'
|
||||||
|
|
||||||
|
export function newGuid(): string {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0
|
||||||
|
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||||
|
return v.toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateDistance(
|
||||||
|
point1: FlightPoint,
|
||||||
|
point2: FlightPoint,
|
||||||
|
aircraftType: string,
|
||||||
|
initialAltitude: number,
|
||||||
|
downang: number,
|
||||||
|
upang: number,
|
||||||
|
): number {
|
||||||
|
if (!point1?.position || !point2?.position) return 0
|
||||||
|
|
||||||
|
const R = 6371
|
||||||
|
const { lat: lat1, lng: lon1 } = point1.position
|
||||||
|
const { lat: lat2, lng: lon2 } = point2.position
|
||||||
|
const alt1 = point1.altitude || 0
|
||||||
|
const alt2 = point2.altitude || 0
|
||||||
|
|
||||||
|
const toRad = (value: number) => (value * Math.PI) / 180
|
||||||
|
const dLat = toRad(lat2 - lat1)
|
||||||
|
const dLon = toRad(lon2 - lon1)
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
|
||||||
|
Math.sin(dLon / 2) * Math.sin(dLon / 2)
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
const horizontalDistance = R * c
|
||||||
|
|
||||||
|
const initialAltitudeKm = initialAltitude / 1000
|
||||||
|
const altitude1Km = alt1 / 1000
|
||||||
|
const altitude2Km = alt2 / 1000
|
||||||
|
|
||||||
|
const descentAngleRad = toRad(downang || 0.01)
|
||||||
|
const ascentAngleRad = toRad(upang || 0.01)
|
||||||
|
|
||||||
|
if (aircraftType === 'Plane') {
|
||||||
|
const ascentDist = Math.max(0, (initialAltitudeKm - altitude1Km) / Math.sin(ascentAngleRad))
|
||||||
|
const descentDist = Math.max(0, (initialAltitudeKm - altitude2Km) / Math.sin(descentAngleRad))
|
||||||
|
const hAscent = Math.max(0, ascentDist * Math.cos(ascentAngleRad))
|
||||||
|
const hDescent = Math.max(0, descentDist * Math.cos(descentAngleRad))
|
||||||
|
return horizontalDistance - (hDescent + hAscent) + Math.max(0, descentDist) + Math.max(0, ascentDist)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ascentVertical = Math.abs(initialAltitudeKm - altitude1Km)
|
||||||
|
const descentVertical = Math.abs(initialAltitudeKm - altitude2Km)
|
||||||
|
return ascentVertical + horizontalDistance + descentVertical
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWeatherData(lat: number, lon: number) {
|
||||||
|
const apiKey = '335799082893fad97fa36118b131f919'
|
||||||
|
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`
|
||||||
|
try {
|
||||||
|
const res = await fetch(url)
|
||||||
|
const data = await res.json()
|
||||||
|
return { windSpeed: data.wind.speed as number, windAngle: data.wind.deg as number }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function calculateBatteryPercentUsed(
|
||||||
|
groundSpeed: number,
|
||||||
|
time: number,
|
||||||
|
position: { lat: number; lon: number },
|
||||||
|
aircraft: AircraftParams,
|
||||||
|
): Promise<number> {
|
||||||
|
const weatherData = await getWeatherData(position.lat, position.lon)
|
||||||
|
const airDensity = 1.05
|
||||||
|
const groundSpeedMs = groundSpeed / 3.6
|
||||||
|
const headwind = (weatherData?.windSpeed ?? 0) * Math.cos((Math.PI / 180) * (weatherData?.windAngle ?? 0))
|
||||||
|
const effectiveAirspeed = groundSpeedMs + headwind
|
||||||
|
const drag = 0.5 * airDensity * (effectiveAirspeed ** 2) * aircraft.dragCoefficient * aircraft.frontalArea
|
||||||
|
const adjustedDrag = drag + aircraft.weight * 9.8 * 0.05
|
||||||
|
|
||||||
|
let watts = aircraft.thrustWatts[aircraft.thrustWatts.length - 1].watts
|
||||||
|
for (const item of aircraft.thrustWatts) {
|
||||||
|
const thrustN = (item.thrust / 1000) * 9.8
|
||||||
|
if (thrustN > adjustedDrag) { watts = item.watts; break }
|
||||||
|
}
|
||||||
|
const power = watts / aircraft.propellerEfficiency
|
||||||
|
const energyUsed = power * time
|
||||||
|
return Math.min((energyUsed / aircraft.batteryCapacity) * 100, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function calculateAllPoints(
|
||||||
|
points: FlightPoint[],
|
||||||
|
aircraft: AircraftParams,
|
||||||
|
initialAltitude: number,
|
||||||
|
): Promise<CalculatedPointInfo[]> {
|
||||||
|
const infos: CalculatedPointInfo[] = [{ bat: 100, time: 0 }]
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const p1 = points[i - 1], p2 = points[i]
|
||||||
|
const dist = calculateDistance(p1, p2, aircraft.type, initialAltitude, aircraft.downang, aircraft.upang)
|
||||||
|
const time = dist / aircraft.speed
|
||||||
|
const midPos = { lat: (p1.position.lat + p2.position.lat) / 2, lon: (p1.position.lng + p2.position.lng) / 2 }
|
||||||
|
const pct = await calculateBatteryPercentUsed(aircraft.speed, time, midPos, aircraft)
|
||||||
|
infos.push({ bat: infos[i - 1].bat - pct, time: infos[i - 1].time + time })
|
||||||
|
}
|
||||||
|
return infos
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCoordinates(input: string): { lat: number; lng: number } | null {
|
||||||
|
const cleaned = input.trim().replace(/[°NSEW]/gi, '')
|
||||||
|
const parts = cleaned.split(/[,\s]+/).filter(Boolean)
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const lat = parseFloat(parts[0])
|
||||||
|
const lng = parseFloat(parts[1])
|
||||||
|
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||||
|
return { lat, lng }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMockAircraftParams(): AircraftParams {
|
||||||
|
return {
|
||||||
|
type: 'Plane',
|
||||||
|
downang: 40,
|
||||||
|
upang: 45,
|
||||||
|
weight: 3.4,
|
||||||
|
speed: 80,
|
||||||
|
frontalArea: 0.12,
|
||||||
|
dragCoefficient: 0.45,
|
||||||
|
batteryCapacity: 315,
|
||||||
|
thrustWatts: [
|
||||||
|
{ thrust: 500, watts: 55.5 },
|
||||||
|
{ thrust: 750, watts: 91.02 },
|
||||||
|
{ thrust: 1000, watts: 137.64 },
|
||||||
|
{ thrust: 1250, watts: 191 },
|
||||||
|
{ thrust: 1500, watts: 246 },
|
||||||
|
{ thrust: 1750, watts: 308 },
|
||||||
|
{ thrust: 2000, watts: 381 },
|
||||||
|
],
|
||||||
|
propellerEfficiency: 0.95,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import L from 'leaflet'
|
||||||
|
|
||||||
|
function pinIcon(color: string) {
|
||||||
|
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],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pointIconGreen = pinIcon('#1ed013')
|
||||||
|
export const pointIconBlue = pinIcon('#228be6')
|
||||||
|
export const pointIconRed = pinIcon('#fa5252')
|
||||||
|
|
||||||
|
export const defaultIcon = new L.Icon({
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png',
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [1, -34],
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type L from 'leaflet'
|
||||||
|
|
||||||
|
export interface FlightPoint {
|
||||||
|
id: string
|
||||||
|
position: { lat: number; lng: number }
|
||||||
|
altitude: number
|
||||||
|
meta: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculatedPointInfo {
|
||||||
|
bat: number
|
||||||
|
time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapRectangle {
|
||||||
|
id: string
|
||||||
|
layer?: L.Layer
|
||||||
|
bounds: L.LatLngBounds
|
||||||
|
color: 'red' | 'green'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AircraftParams {
|
||||||
|
type: string
|
||||||
|
downang: number
|
||||||
|
upang: number
|
||||||
|
weight: number
|
||||||
|
speed: number
|
||||||
|
frontalArea: number
|
||||||
|
dragCoefficient: number
|
||||||
|
batteryCapacity: number
|
||||||
|
thrustWatts: { thrust: number; watts: number }[]
|
||||||
|
propellerEfficiency: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindParams {
|
||||||
|
direction: number
|
||||||
|
speed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovingPointInfo {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
latlng: L.LatLng
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionMode = 'points' | 'workArea' | 'prohibitedArea'
|
||||||
|
|
||||||
|
export const PURPOSES = [
|
||||||
|
{ value: 'tank', label: 'options.tank' },
|
||||||
|
{ value: 'artillery', label: 'options.artillery' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const COORDINATE_PRECISION = 8
|
||||||
|
|
||||||
|
export const TILE_URLS = {
|
||||||
|
classic: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
} as const
|
||||||
+50
-1
@@ -33,7 +33,56 @@
|
|||||||
"liveGps": "Live GPS",
|
"liveGps": "Live GPS",
|
||||||
"correction": "GPS Correction",
|
"correction": "GPS Correction",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"telemetry": "Telemetry"
|
"telemetry": "Telemetry",
|
||||||
|
"planner": {
|
||||||
|
"point": "Point",
|
||||||
|
"altitude": "Altitude",
|
||||||
|
"initialAltitude": "Initial Altitude",
|
||||||
|
"addPoints": "Points",
|
||||||
|
"workArea": "Work Area",
|
||||||
|
"prohibitedArea": "No-Go Zone",
|
||||||
|
"location": "Location",
|
||||||
|
"currentLocation": "Current location",
|
||||||
|
"operations": "Operations",
|
||||||
|
"editAsJson": "Edit JSON",
|
||||||
|
"exportMapData": "Export",
|
||||||
|
"save": "Save",
|
||||||
|
"upload": "Upload",
|
||||||
|
"titleAdd": "Add New Point",
|
||||||
|
"titleEdit": "Edit Point",
|
||||||
|
"description": "Enter the coordinates, altitude, and purpose of the point.",
|
||||||
|
"latitude": "Latitude",
|
||||||
|
"longitude": "Longitude",
|
||||||
|
"purpose": "Purpose",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"submitAdd": "Add Point",
|
||||||
|
"submitEdit": "Save Changes",
|
||||||
|
"removePoint": "Delete",
|
||||||
|
"windSpeed": "Wind spd",
|
||||||
|
"windDirection": "Wind dir",
|
||||||
|
"setWind": "Set Wind",
|
||||||
|
"battery": "bat.",
|
||||||
|
"metres": "m",
|
||||||
|
"km": "km",
|
||||||
|
"hour": "h",
|
||||||
|
"minutes": "min",
|
||||||
|
"calculated": "calculated",
|
||||||
|
"error": "Calculation error",
|
||||||
|
"statusGood": "Good",
|
||||||
|
"statusCaution": "Caution",
|
||||||
|
"statusLow": "Can't complete",
|
||||||
|
"options": {
|
||||||
|
"artillery": "Artillery",
|
||||||
|
"tank": "Tank"
|
||||||
|
},
|
||||||
|
"invalidJson": "Invalid JSON format",
|
||||||
|
"editJsonHint": "Edit the JSON data as needed.",
|
||||||
|
"satellite": "Satellite",
|
||||||
|
"cameraFov": "Camera FOV / Length / Field",
|
||||||
|
"cameraFovPlaceholder": "FOV parameters",
|
||||||
|
"commAddr": "Communication Addr / Port",
|
||||||
|
"commAddrPlaceholder": "192.168.1.1:8080"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Annotations",
|
"title": "Annotations",
|
||||||
|
|||||||
+50
-1
@@ -33,7 +33,56 @@
|
|||||||
"liveGps": "GPS Потік",
|
"liveGps": "GPS Потік",
|
||||||
"correction": "Корекція GPS",
|
"correction": "Корекція GPS",
|
||||||
"apply": "Застосувати",
|
"apply": "Застосувати",
|
||||||
"telemetry": "Телеметрія"
|
"telemetry": "Телеметрія",
|
||||||
|
"planner": {
|
||||||
|
"point": "Точка",
|
||||||
|
"altitude": "Висота",
|
||||||
|
"initialAltitude": "Початкова висота",
|
||||||
|
"addPoints": "Точки",
|
||||||
|
"workArea": "Робоча зона",
|
||||||
|
"prohibitedArea": "Заборонена зона",
|
||||||
|
"location": "Місцезнаходження",
|
||||||
|
"currentLocation": "Поточне місцезнаходження",
|
||||||
|
"operations": "Операції",
|
||||||
|
"editAsJson": "Редагувати JSON",
|
||||||
|
"exportMapData": "Експорт",
|
||||||
|
"save": "Зберегти",
|
||||||
|
"upload": "Завантажити",
|
||||||
|
"titleAdd": "Додати нову точку",
|
||||||
|
"titleEdit": "Редагувати точку",
|
||||||
|
"description": "Введіть координати, висоту та мету точки.",
|
||||||
|
"latitude": "Широта",
|
||||||
|
"longitude": "Довгота",
|
||||||
|
"purpose": "Мета",
|
||||||
|
"cancel": "Скасувати",
|
||||||
|
"submitAdd": "Додати точку",
|
||||||
|
"submitEdit": "Зберегти зміни",
|
||||||
|
"removePoint": "Видалити",
|
||||||
|
"windSpeed": "Шв. вітру",
|
||||||
|
"windDirection": "Напр. вітру",
|
||||||
|
"setWind": "Вітер",
|
||||||
|
"battery": "бат.",
|
||||||
|
"metres": "м",
|
||||||
|
"km": "км",
|
||||||
|
"hour": "год",
|
||||||
|
"minutes": "хв",
|
||||||
|
"calculated": "розрахункова",
|
||||||
|
"error": "Помилка розрахунку",
|
||||||
|
"statusGood": "Долетить",
|
||||||
|
"statusCaution": "Є ризики",
|
||||||
|
"statusLow": "Не долетить",
|
||||||
|
"options": {
|
||||||
|
"artillery": "Артилерія",
|
||||||
|
"tank": "Танк"
|
||||||
|
},
|
||||||
|
"invalidJson": "Невірний JSON формат",
|
||||||
|
"editJsonHint": "Відредагуйте JSON дані за потреби.",
|
||||||
|
"satellite": "Супутник",
|
||||||
|
"cameraFov": "Камера FOV / Фокус",
|
||||||
|
"cameraFovPlaceholder": "Параметри FOV",
|
||||||
|
"commAddr": "Адреса / Порт",
|
||||||
|
"commAddrPlaceholder": "192.168.1.1:8080"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Анотації",
|
"title": "Анотації",
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.css'
|
||||||
|
declare module 'leaflet-polylinedecorator'
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/dataset/datasetpage.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightspage.tsx","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/dataset/datasetpage.tsx","./src/features/flights/altitudechart.tsx","./src/features/flights/altitudedialog.tsx","./src/features/flights/drawcontrol.tsx","./src/features/flights/flightlistsidebar.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightparamspanel.tsx","./src/features/flights/flightspage.tsx","./src/features/flights/jsoneditordialog.tsx","./src/features/flights/mappoint.tsx","./src/features/flights/minimap.tsx","./src/features/flights/waypointlist.tsx","./src/features/flights/windeffect.tsx","./src/features/flights/flightplanutils.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||||
Reference in New Issue
Block a user