1 Commits

Author SHA1 Message Date
Armen Rohalov 63cc18e788 Enhance annotations: save/download, fallback classes, photo mode icons
Add local annotation save fallback, PNG+txt download with drawn boxes,
shared classColors helper, photo mode icon toggles, and react-dropzone
/ react-icons dependencies.
2026-04-17 23:33:00 +03:00
33 changed files with 910 additions and 1704 deletions
-1
View File
@@ -1,7 +1,6 @@
.idea .idea
.claude .claude
.superpowers .superpowers
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
File diff suppressed because one or more lines are too long
+164
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-63
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AZAION</title> <title>AZAION</title>
<script type="module" crossorigin src="/assets/index-o2ENlayJ.js"></script> <script type="module" crossorigin src="/assets/index-B-KLvAXK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--amdfC0Y.css"> <link rel="stylesheet" crossorigin href="/assets/index-Du68yxJU.css">
</head> </head>
<body class="bg-[#1e1e1e] text-[#adb5bd]"> <body class="bg-[#1e1e1e] text-[#adb5bd]">
<div id="root"></div> <div id="root"></div>
+2 -9
View File
@@ -9,26 +9,19 @@
"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-dropzone": "^15.0.0",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
"react-icons": "^5.6.0",
"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",
+44 -17
View File
@@ -1,6 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
import { FaRegSnowflake } from 'react-icons/fa'
import { api } from '../api/client' import { api } from '../api/client'
import { getClassColor, FALLBACK_CLASS_NAMES } from '../features/annotations/classColors'
import type { DetectionClass } from '../types' import type { DetectionClass } from '../types'
interface Props { interface Props {
@@ -10,12 +13,25 @@ interface Props {
onPhotoModeChange: (mode: number) => void onPhotoModeChange: (mode: number) => void
} }
const FALLBACK_CLASSES: DetectionClass[] = [0, 20, 40].flatMap(modeOffset =>
FALLBACK_CLASS_NAMES.map((name, i) => ({
id: i + modeOffset,
name,
shortName: name.slice(0, 3),
color: getClassColor(i),
maxSizeM: 10,
photoMode: modeOffset,
})),
)
export default function DetectionClasses({ selectedClassNum, onSelect, photoMode, onPhotoModeChange }: Props) { export default function DetectionClasses({ selectedClassNum, onSelect, photoMode, onPhotoModeChange }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const [classes, setClasses] = useState<DetectionClass[]>([]) const [classes, setClasses] = useState<DetectionClass[]>([])
useEffect(() => { useEffect(() => {
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {}) api.get<DetectionClass[]>('/api/annotations/classes')
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
.catch(() => setClasses(FALLBACK_CLASSES))
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -31,27 +47,25 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
return () => window.removeEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler)
}, [classes, photoMode, onSelect]) }, [classes, photoMode, onSelect])
// Auto-select first class of current photoMode when mode changes or classes load
useEffect(() => {
const modeClasses = classes.filter(c => c.photoMode === photoMode)
const currentIsInMode = modeClasses.some(c => c.id === selectedClassNum)
if (!currentIsInMode && modeClasses.length > 0) {
onSelect(modeClasses[0].id)
}
}, [classes, photoMode, selectedClassNum, onSelect])
const modes = [ const modes = [
{ value: 0, label: t('annotations.regular') }, { value: 0, label: t('annotations.regular'), icon: <MdOutlineWbSunny />, activeClass: 'bg-az-orange text-white', iconColor: 'text-az-orange' },
{ value: 20, label: t('annotations.winter') }, { value: 20, label: t('annotations.winter'), icon: <FaRegSnowflake />, activeClass: 'bg-az-blue text-white', iconColor: 'text-az-blue' },
{ value: 40, label: t('annotations.night') }, { value: 40, label: t('annotations.night'), icon: <MdOutlineNightlightRound />, activeClass: 'bg-purple-600 text-white', iconColor: 'text-purple-400' },
] ]
return ( return (
<div className="border-t border-az-border p-2"> <div className="border-t border-az-border p-2">
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div> <div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div>
<div className="flex gap-1 mb-2"> <div className="space-y-0.5 max-h-48 overflow-y-auto mb-2">
{modes.map(m => (
<button
key={m.value}
onClick={() => onPhotoModeChange(m.value)}
className={`text-xs px-2 py-0.5 rounded ${photoMode === m.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`}
>
{m.label}
</button>
))}
</div>
<div className="space-y-0.5 max-h-48 overflow-y-auto">
{classes.filter(c => c.photoMode === photoMode).map((c, i) => ( {classes.filter(c => c.photoMode === photoMode).map((c, i) => (
<button <button
key={c.id} key={c.id}
@@ -60,13 +74,26 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
selectedClassNum === c.id ? 'bg-az-border text-white' : 'text-az-text hover:bg-az-bg' selectedClassNum === c.id ? 'bg-az-border text-white' : 'text-az-text hover:bg-az-bg'
}`} }`}
> >
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: c.color }} /> <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getClassColor(c.id) }} />
<span className="text-az-muted">{i + 1}.</span> <span className="text-az-muted">{i + 1}.</span>
<span className="truncate">{c.name}</span> <span className="truncate">{c.name}</span>
<span className="text-az-muted ml-auto">{c.shortName}</span> <span className="text-az-muted ml-auto">{c.shortName}</span>
</button> </button>
))} ))}
</div> </div>
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.photoMode')}</div>
<div className="flex gap-1">
{modes.map(m => (
<button
key={m.value}
onClick={() => onPhotoModeChange(m.value)}
title={m.label}
className={`flex-1 flex items-center justify-center px-2 py-1 rounded text-base ${photoMode === m.value ? m.activeClass : `bg-az-bg ${m.iconColor} hover:brightness-125`}`}
>
{m.icon}
</button>
))}
</div>
</div> </div>
) )
} }
+192 -9
View File
@@ -1,10 +1,13 @@
import { useState, useCallback } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { useResizablePanel } from '../../hooks/useResizablePanel' import { useResizablePanel } from '../../hooks/useResizablePanel'
import { api } from '../../api/client'
import MediaList from './MediaList' import MediaList from './MediaList'
import VideoPlayer from './VideoPlayer' import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
import CanvasEditor from './CanvasEditor' import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
import AnnotationsSidebar from './AnnotationsSidebar' import AnnotationsSidebar from './AnnotationsSidebar'
import DetectionClasses from '../../components/DetectionClasses' import DetectionClasses from '../../components/DetectionClasses'
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
import type { Media, AnnotationListItem, Detection } from '../../types' import type { Media, AnnotationListItem, Detection } from '../../types'
export default function AnnotationsPage() { export default function AnnotationsPage() {
@@ -17,17 +20,158 @@ export default function AnnotationsPage() {
const [detections, setDetections] = useState<Detection[]>([]) const [detections, setDetections] = useState<Detection[]>([])
const leftPanel = useResizablePanel(250, 200, 400) const leftPanel = useResizablePanel(250, 200, 400)
const rightPanel = useResizablePanel(200, 150, 350) const rightPanel = useResizablePanel(200, 150, 350)
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
const canvasRef = useRef<CanvasEditorHandle>(null)
useEffect(() => {
setDetections([])
setSelectedAnnotation(null)
setCurrentTime(0)
}, [selectedMedia])
const handleSave = useCallback(async () => {
if (!selectedMedia || !detections.length) return
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
const body = { mediaId: selectedMedia.id, time, detections }
if (!selectedMedia.path.startsWith('blob:')) {
try {
await api.post('/api/annotations/annotations', body)
const res = await api.get<{ items: AnnotationListItem[] }>(
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
)
setAnnotations(res.items)
return
} catch {
// fall through to local save
}
}
const local: AnnotationListItem = {
id: `local-${crypto.randomUUID()}`,
mediaId: selectedMedia.id,
time,
createdDate: new Date().toISOString(),
userId: 'local',
source: AnnotationSource.Manual,
status: AnnotationStatus.Created,
isSplit: false,
splitTile: null,
detections: [...detections],
}
setAnnotations(prev => [...prev, local])
}, [selectedMedia, detections, currentTime])
const handleDownload = useCallback(async (ann: AnnotationListItem) => {
if (!selectedMedia) return
const txt = ann.detections
.map(d => `${d.classNum} ${d.centerX.toFixed(6)} ${d.centerY.toFixed(6)} ${d.width.toFixed(6)} ${d.height.toFixed(6)}`)
.join('\n')
const stem = `annotation_${new Date().toISOString().replace(/[:.]/g, '-')}`
const txtBlob = new Blob([txt], { type: 'text/plain' })
const txtUrl = URL.createObjectURL(txtBlob)
const txtA = document.createElement('a')
txtA.href = txtUrl
txtA.download = `${stem}.txt`
txtA.click()
URL.revokeObjectURL(txtUrl)
// Build the image: video frame or image with rectangles drawn
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
let w = 0, h = 0
const canvas = document.createElement('canvas')
if (videoEl && videoEl.videoWidth) {
w = videoEl.videoWidth
h = videoEl.videoHeight
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (ctx) ctx.drawImage(videoEl, 0, 0, w, h)
} else if (!selectedMedia.path) {
return
} else {
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = selectedMedia.path.startsWith('blob:')
? selectedMedia.path
: `/api/annotations/media/${selectedMedia.id}/file`
await new Promise(res => { img.onload = res; img.onerror = res })
w = img.naturalWidth
h = img.naturalHeight
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (ctx) ctx.drawImage(img, 0, 0, w, h)
}
if (w && h) {
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.lineWidth = 2
ctx.font = '14px sans-serif'
const labelH = 17
const padX = 4
for (const d of ann.detections) {
const bx = (d.centerX - d.width / 2) * w
const by = (d.centerY - d.height / 2) * h
const bw = d.width * w
const bh = d.height * h
const color = getClassColor(d.classNum)
ctx.strokeStyle = color
ctx.strokeRect(bx, by, bw, bh)
const name = d.label || getClassNameFallback(d.classNum)
const modeSuffix = getPhotoModeSuffix(d.classNum)
const confSuffix = d.confidence < 0.995 ? ` ${(d.confidence * 100).toFixed(0)}%` : ''
const label = `${name}${modeSuffix}${confSuffix}`
const metrics = ctx.measureText(label)
const labelW = metrics.width + padX * 2
ctx.fillStyle = color
ctx.fillRect(bx, Math.max(0, by - labelH), labelW, labelH)
ctx.fillStyle = '#000'
ctx.fillText(label, bx + padX, Math.max(13, by - 4))
}
}
canvas.toBlob(blob => {
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${stem}.png`
a.click()
URL.revokeObjectURL(url)
}, 'image/png')
}
}, [selectedMedia])
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => { const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
setSelectedAnnotation(ann) setSelectedAnnotation(ann)
setDetections(ann.detections) setDetections(ann.detections)
if (ann.time) {
const parts = ann.time.split(':').map(Number)
const seconds = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
videoPlayerRef.current?.seek(seconds)
setCurrentTime(seconds)
}
}, []) }, [])
const handleDetectionsChange = useCallback((dets: Detection[]) => { const handleDetectionsChange = useCallback((dets: Detection[]) => {
setDetections(dets) setDetections(dets)
}, []) }, [])
const isVideo = selectedMedia?.mediaType === 2 const isVideo = selectedMedia?.mediaType === MediaType.Video
function formatTicks(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
}
return ( return (
<div className="flex h-full"> <div className="flex h-full">
@@ -48,16 +192,54 @@ export default function AnnotationsPage() {
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" /> <div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
{/* Center - video/canvas */} {/* Center - video/canvas */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col min-h-0">
{selectedMedia && (
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
<button
onClick={handleSave}
disabled={!detections.length}
className="px-2.5 py-1 rounded border border-az-green text-az-green text-[11px] hover:bg-az-green/10 disabled:opacity-40 disabled:cursor-not-allowed"
>
Save
</button>
<button
onClick={() => canvasRef.current?.deleteSelected()}
disabled={!detections.length}
className="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10 disabled:opacity-40 disabled:cursor-not-allowed"
>
Remove
</button>
<button
onClick={() => canvasRef.current?.deleteAll()}
disabled={!detections.length}
className="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10 disabled:opacity-40 disabled:cursor-not-allowed"
>
Remove All
</button>
<span className="text-az-muted text-[10px]">{detections.length} detection{detections.length !== 1 ? 's' : ''}</span>
</div>
)}
{selectedMedia && isVideo && ( {selectedMedia && isVideo && (
<VideoPlayer <VideoPlayer
ref={videoPlayerRef}
media={selectedMedia} media={selectedMedia}
onTimeUpdate={setCurrentTime} onTimeUpdate={setCurrentTime}
selectedClassNum={selectedClassNum} >
/>
)}
{selectedMedia && (
<CanvasEditor <CanvasEditor
ref={canvasRef}
media={selectedMedia}
annotation={selectedAnnotation}
detections={detections}
onDetectionsChange={handleDetectionsChange}
selectedClassNum={selectedClassNum}
currentTime={currentTime}
annotations={annotations}
/>
</VideoPlayer>
)}
{selectedMedia && !isVideo && (
<CanvasEditor
ref={canvasRef}
media={selectedMedia} media={selectedMedia}
annotation={selectedAnnotation} annotation={selectedAnnotation}
detections={detections} detections={detections}
@@ -83,6 +265,7 @@ export default function AnnotationsPage() {
selectedAnnotation={selectedAnnotation} selectedAnnotation={selectedAnnotation}
onSelect={handleAnnotationSelect} onSelect={handleAnnotationSelect}
onAnnotationsUpdate={setAnnotations} onAnnotationsUpdate={setAnnotations}
onDownload={handleDownload}
/> />
</div> </div>
</div> </div>
+16 -10
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FaDownload } from 'react-icons/fa'
import { api } from '../../api/client' import { api } from '../../api/client'
import { createSSE } from '../../api/sse' import { createSSE } from '../../api/sse'
import { getClassColor } from './classColors'
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types' import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
interface Props { interface Props {
@@ -10,9 +12,10 @@ interface Props {
selectedAnnotation: AnnotationListItem | null selectedAnnotation: AnnotationListItem | null
onSelect: (ann: AnnotationListItem) => void onSelect: (ann: AnnotationListItem) => void
onAnnotationsUpdate: (anns: AnnotationListItem[]) => void onAnnotationsUpdate: (anns: AnnotationListItem[]) => void
onDownload?: (ann: AnnotationListItem) => void
} }
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate }: Props) { export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const [detecting, setDetecting] = useState(false) const [detecting, setDetecting] = useState(false)
const [detectLog, setDetectLog] = useState<string[]>([]) const [detectLog, setDetectLog] = useState<string[]>([])
@@ -45,22 +48,16 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
const stops = ann.detections.map((d, i) => { const stops = ann.detections.map((d, i) => {
const pct = (i / Math.max(ann.detections.length - 1, 1)) * 100 const pct = (i / Math.max(ann.detections.length - 1, 1)) * 100
const alpha = Math.min(1, d.confidence) const alpha = Math.min(1, d.confidence)
return `${d.label ? getClassColor(d.classNum) : '#888'}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%` return `${getClassColor(d.classNum)}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
}) })
return `linear-gradient(to right, ${stops.join(', ')})` return `linear-gradient(to right, ${stops.join(', ')})`
} }
const classColors: Record<number, string> = {}
const getClassColor = (classNum: number) => {
if (classColors[classNum]) return classColors[classNum]
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', '#188021', '#800000', '#008000', '#000080']
return colors[classNum % colors.length]
}
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="p-2 border-b border-az-border flex items-center justify-between"> <div className="p-2 border-b border-az-border flex items-center justify-between gap-1">
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span> <span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
<div className="flex items-center gap-1">
<button <button
onClick={handleDetect} onClick={handleDetect}
disabled={!media} disabled={!media}
@@ -68,6 +65,15 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
> >
{t('annotations.detect')} {t('annotations.detect')}
</button> </button>
<button
onClick={() => selectedAnnotation && onDownload?.(selectedAnnotation)}
disabled={!selectedAnnotation}
title="Download annotation"
className="text-xs bg-az-orange text-white p-1 rounded disabled:opacity-50"
>
<FaDownload size={12} />
</button>
</div>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
+75 -14
View File
@@ -1,5 +1,7 @@
import { useRef, useEffect, useState, useCallback } from 'react' import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import { MediaType } from '../../types'
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types' import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from './classColors'
interface Props { interface Props {
media: Media media: Media
@@ -11,6 +13,12 @@ interface Props {
annotations: AnnotationListItem[] annotations: AnnotationListItem[]
} }
export interface CanvasEditorHandle {
deleteSelected: () => void
deleteAll: () => void
hasSelection: () => boolean
}
interface DragState { interface DragState {
type: 'draw' | 'move' | 'resize' type: 'draw' | 'move' | 'resize'
startX: number startX: number
@@ -28,7 +36,10 @@ const AFFILIATION_COLORS: Record<number, string> = {
2: '#fa5252', 2: '#fa5252',
} }
export default function CanvasEditor({ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations }: Props) { const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations },
ref,
) {
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement | null>(null) const imgRef = useRef<HTMLImageElement | null>(null)
@@ -39,11 +50,35 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null) const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
const [imgSize, setImgSize] = useState({ w: 0, h: 0 }) const [imgSize, setImgSize] = useState({ w: 0, h: 0 })
useImperativeHandle(ref, () => ({
deleteSelected() {
if (selected.size === 0) return
onDetectionsChange(detections.filter((_, i) => !selected.has(i)))
setSelected(new Set())
},
deleteAll() {
onDetectionsChange([])
setSelected(new Set())
},
hasSelection() {
return selected.size > 0
},
}), [selected, detections, onDetectionsChange])
const isVideo = media.mediaType === MediaType.Video
const loadImage = useCallback(() => { const loadImage = useCallback(() => {
if (isVideo) {
// Use natural size based on container; no image load
imgRef.current = null
return
}
const img = new Image() const img = new Image()
img.crossOrigin = 'anonymous' img.crossOrigin = 'anonymous'
if (annotation) { if (annotation && !media.path.startsWith('blob:')) {
img.src = `/api/annotations/annotations/${annotation.id}/image` img.src = `/api/annotations/annotations/${annotation.id}/image`
} else if (media.path.startsWith('blob:')) {
img.src = media.path
} else { } else {
img.src = `/api/annotations/media/${media.id}/file` img.src = `/api/annotations/media/${media.id}/file`
} }
@@ -51,10 +86,22 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
imgRef.current = img imgRef.current = img
setImgSize({ w: img.naturalWidth, h: img.naturalHeight }) setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
} }
}, [media, annotation]) }, [media, annotation, isVideo])
useEffect(() => { loadImage() }, [loadImage]) useEffect(() => { loadImage() }, [loadImage])
useEffect(() => {
if (!isVideo || !containerRef.current) return
const update = () => {
const c = containerRef.current
if (c) setImgSize({ w: c.clientWidth, h: c.clientHeight })
}
update()
const ro = new ResizeObserver(update)
ro.observe(containerRef.current)
return () => ro.disconnect()
}, [isVideo])
const toCanvas = useCallback((nx: number, ny: number) => ({ const toCanvas = useCallback((nx: number, ny: number) => ({
x: nx * imgSize.w * zoom + pan.x, x: nx * imgSize.w * zoom + pan.x,
y: ny * imgSize.h * zoom + pan.y, y: ny * imgSize.h * zoom + pan.y,
@@ -68,7 +115,8 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
const draw = useCallback(() => { const draw = useCallback(() => {
const canvas = canvasRef.current const canvas = canvasRef.current
const ctx = canvas?.getContext('2d') const ctx = canvas?.getContext('2d')
if (!canvas || !ctx || !imgRef.current) return if (!canvas || !ctx) return
if (!isVideo && !imgRef.current) return
const container = containerRef.current const container = containerRef.current
if (container) { if (container) {
@@ -78,7 +126,9 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.save() ctx.save()
if (!isVideo && imgRef.current) {
ctx.drawImage(imgRef.current, pan.x, pan.y, imgSize.w * zoom, imgSize.h * zoom) ctx.drawImage(imgRef.current, pan.x, pan.y, imgSize.w * zoom, imgSize.h * zoom)
}
const timeWindowDets = getTimeWindowDetections() const timeWindowDets = getTimeWindowDetections()
const allDets = [...detections, ...timeWindowDets] const allDets = [...detections, ...timeWindowDets]
@@ -90,7 +140,7 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
const w = det.width * imgSize.w * zoom const w = det.width * imgSize.w * zoom
const h = det.height * imgSize.h * zoom const h = det.height * imgSize.h * zoom
const color = AFFILIATION_COLORS[det.affiliation] || '#FFD700' const color = getClassColor(det.classNum)
ctx.strokeStyle = color ctx.strokeStyle = color
ctx.lineWidth = isSelected ? 2 : 1 ctx.lineWidth = isSelected ? 2 : 1
ctx.strokeRect(cx, cy, w, h) ctx.strokeRect(cx, cy, w, h)
@@ -100,12 +150,20 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
ctx.fillRect(cx, cy, w, h) ctx.fillRect(cx, cy, w, h)
ctx.globalAlpha = 1 ctx.globalAlpha = 1
const label = det.confidence < 0.995 const name = det.label || getClassNameFallback(det.classNum)
? `${det.label} ${(det.confidence * 100).toFixed(0)}%` const modeSuffix = getPhotoModeSuffix(det.classNum)
: det.label const confSuffix = det.confidence < 0.995 ? ` ${(det.confidence * 100).toFixed(0)}%` : ''
ctx.fillStyle = color const label = `${name}${modeSuffix}${confSuffix}`
ctx.font = '11px sans-serif' ctx.font = '11px sans-serif'
ctx.fillText(label, cx + 2, cy - 3) const metrics = ctx.measureText(label)
const padX = 3
const labelH = 14
const labelW = metrics.width + padX * 2
ctx.fillStyle = color
ctx.fillRect(cx, cy - labelH, labelW, labelH)
ctx.fillStyle = '#000'
ctx.fillText(label, cx + padX, cy - 3)
if (det.combatReadiness === 1) { if (det.combatReadiness === 1) {
ctx.fillStyle = '#40c057' ctx.fillStyle = '#40c057'
@@ -150,7 +208,8 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
}, [draw]) }, [draw])
const getTimeWindowDetections = (): Detection[] => { const getTimeWindowDetections = (): Detection[] => {
if (media.mediaType !== 2) return [] if (media.mediaType !== MediaType.Video) return []
if (annotation) return []
const timeTicks = currentTime * 10_000_000 const timeTicks = currentTime * 10_000_000
return annotations return annotations
.filter(a => { .filter(a => {
@@ -332,7 +391,7 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
}, [detections, selected, onDetectionsChange]) }, [detections, selected, onDetectionsChange])
return ( return (
<div ref={containerRef} className="flex-1 relative overflow-hidden cursor-crosshair"> <div ref={containerRef} className="w-full h-full flex-1 relative overflow-hidden cursor-crosshair">
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="absolute inset-0" className="absolute inset-0"
@@ -344,4 +403,6 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
/> />
</div> </div>
) )
} })
export default CanvasEditor
+124 -28
View File
@@ -1,9 +1,11 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import { useFlight } from '../../components/FlightContext' import { useFlight } from '../../components/FlightContext'
import { api } from '../../api/client' import { api } from '../../api/client'
import { useDebounce } from '../../hooks/useDebounce' import { useDebounce } from '../../hooks/useDebounce'
import ConfirmDialog from '../../components/ConfirmDialog' import ConfirmDialog from '../../components/ConfirmDialog'
import { MediaType } from '../../types'
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types' import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
interface Props { interface Props {
@@ -19,7 +21,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const debouncedFilter = useDebounce(filter, 300) const debouncedFilter = useDebounce(filter, 300)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
const [dragging, setDragging] = useState(false) const folderInputRef = useRef<HTMLInputElement>(null)
const fetchMedia = useCallback(async () => { const fetchMedia = useCallback(async () => {
const params = new URLSearchParams({ pageSize: '1000' }) const params = new URLSearchParams({ pageSize: '1000' })
@@ -27,57 +29,124 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
if (debouncedFilter) params.set('name', debouncedFilter) if (debouncedFilter) params.set('name', debouncedFilter)
try { try {
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`) const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
setMedia(res.items) setMedia(prev => {
} catch {} // Keep local-only (blob URL) entries, merge with backend entries
const local = prev.filter(m => m.path.startsWith('blob:'))
return [...local, ...res.items]
})
} catch {
// backend unavailable — keep local-only entries
}
}, [selectedFlight, debouncedFilter]) }, [selectedFlight, debouncedFilter])
useEffect(() => { fetchMedia() }, [fetchMedia]) useEffect(() => { fetchMedia() }, [fetchMedia])
useEffect(() => {
return () => {
for (const m of media) if (m.path.startsWith('blob:')) URL.revokeObjectURL(m.path)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSelect = async (m: Media) => { const handleSelect = async (m: Media) => {
onSelect(m) onSelect(m)
if (m.path.startsWith('blob:')) {
onAnnotationsLoaded([])
return
}
try { try {
const res = await api.get<PaginatedResponse<AnnotationListItem>>( const res = await api.get<PaginatedResponse<AnnotationListItem>>(
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000` `/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
) )
onAnnotationsLoaded(res.items) onAnnotationsLoaded(res.items)
} catch {} } catch {
onAnnotationsLoaded([])
}
} }
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteId) return if (!deleteId) return
await api.delete(`/api/annotations/media/${deleteId}`) const target = media.find(m => m.id === deleteId)
if (target?.path.startsWith('blob:')) {
URL.revokeObjectURL(target.path)
setMedia(prev => prev.filter(m => m.id !== deleteId))
setDeleteId(null)
return
}
try { await api.delete(`/api/annotations/media/${deleteId}`) } catch {}
setDeleteId(null) setDeleteId(null)
fetchMedia() fetchMedia()
} }
const handleDrop = async (e: React.DragEvent) => { const uploadFiles = useCallback(async (files: File[] | FileList) => {
e.preventDefault() if (!files.length) return
setDragging(false) const arr = Array.from(files)
if (!selectedFlight || !e.dataTransfer.files.length) return
// Try backend first
if (selectedFlight) {
try {
const form = new FormData() const form = new FormData()
form.append('waypointId', '') form.append('waypointId', '')
for (const file of e.dataTransfer.files) form.append('files', file) for (const file of arr) form.append('files', file)
await api.upload('/api/annotations/media/batch', form) await api.upload('/api/annotations/media/batch', form)
fetchMedia() fetchMedia()
return
} catch {
// fall through to local mode
}
} }
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { // Local mode: add blob URL entries to state
if (!e.target.files?.length) return const videoExts = /\.(mp4|mov|webm|mkv|avi|m4v|ogg|ogv)$/i
const form = new FormData() const imageExts = /\.(jpe?g|png|webp|gif|bmp|tiff?)$/i
form.append('waypointId', '') const accepted: File[] = []
for (const file of e.target.files) form.append('files', file) const rejected: string[] = []
await api.upload('/api/annotations/media/batch', form) for (const file of arr) {
fetchMedia() const isVideo = file.type.startsWith('video/') || videoExts.test(file.name)
const isImage = file.type.startsWith('image/') || imageExts.test(file.name)
if (isVideo || isImage) accepted.push(file)
else rejected.push(file.name)
}
if (rejected.length) {
alert(`Unsupported file type (video/image only):\n${rejected.join('\n')}`)
}
const localItems: Media[] = accepted.map(file => {
const isVideo = file.type.startsWith('video/') || videoExts.test(file.name)
return {
id: `local-${crypto.randomUUID()}`,
name: file.name,
path: URL.createObjectURL(file),
mediaType: isVideo ? MediaType.Video : MediaType.Image,
mediaStatus: 0,
duration: null,
annotationCount: 0,
waypointId: null,
userId: 'local',
}
})
setMedia(prev => [...localItems, ...prev])
}, [selectedFlight, fetchMedia])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: uploadFiles,
multiple: true,
noClick: true,
noKeyboard: true,
})
const handleFolderInput = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) uploadFiles(e.target.files)
e.target.value = '' e.target.value = ''
} }
return ( return (
<div <div
className={`flex-1 flex flex-col overflow-hidden ${dragging ? 'ring-2 ring-az-orange ring-inset' : ''}`} {...getRootProps({
onDragOver={e => { e.preventDefault(); setDragging(true) }} className: `flex-1 flex flex-col overflow-hidden ${isDragActive ? 'ring-2 ring-az-orange ring-inset' : ''}`,
onDragLeave={() => setDragging(false)} })}
onDrop={handleDrop}
> >
<input {...getInputProps()} />
<div className="p-2 border-b border-az-border flex gap-1"> <div className="p-2 border-b border-az-border flex gap-1">
<input <input
value={filter} value={filter}
@@ -85,13 +154,40 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
placeholder={t('annotations.mediaList')} placeholder={t('annotations.mediaList')}
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
/> />
<label className="bg-az-orange text-white text-xs px-2 py-1 rounded cursor-pointer"> </div>
<div className="px-2 pt-2 pb-2 flex gap-1">
<input type="file" multiple className="hidden" onChange={handleFileUpload} /> <label className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded text-center cursor-pointer hover:brightness-110">
Open File
<input
type="file"
multiple
className="hidden"
onChange={e => {
if (e.target.files?.length) uploadFiles(e.target.files)
e.target.value = ''
}}
/>
</label> </label>
<button
type="button"
onClick={() => folderInputRef.current?.click()}
className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded hover:brightness-110"
>
Open Folder
</button>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
// @ts-expect-error webkitdirectory is non-standard but widely supported
webkitdirectory=""
directory=""
onChange={handleFolderInput}
/>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{media.map(m => ( {media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())).map(m => (
<div <div
key={m.id} key={m.id}
onClick={() => handleSelect(m)} onClick={() => handleSelect(m)}
@@ -100,8 +196,8 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
selectedMedia?.id === m.id ? 'bg-az-bg text-white' : '' selectedMedia?.id === m.id ? 'bg-az-bg text-white' : ''
} ${m.annotationCount > 0 ? 'bg-az-bg/50' : ''} text-az-text hover:bg-az-bg`} } ${m.annotationCount > 0 ? 'bg-az-bg/50' : ''} text-az-text hover:bg-az-bg`}
> >
<span className={`font-mono text-[10px] px-1 rounded ${m.mediaType === 2 ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}> <span className={`font-mono text-[10px] px-1 rounded ${m.mediaType === MediaType.Video ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
{m.mediaType === 2 ? 'V' : 'P'} {m.mediaType === MediaType.Video ? 'V' : 'P'}
</span> </span>
<span className="truncate flex-1">{m.name}</span> <span className="truncate flex-1">{m.name}</span>
{m.duration && <span className="text-az-muted">{m.duration}</span>} {m.duration && <span className="text-az-muted">{m.duration}</span>}
+98 -39
View File
@@ -1,25 +1,44 @@
import { useRef, useState, useCallback, useEffect } from 'react' import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next' import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
import { api } from '../../api/client'
import { getToken } from '../../api/client'
import type { Media } from '../../types' import type { Media } from '../../types'
interface Props { interface Props {
media: Media media: Media
onTimeUpdate: (time: number) => void onTimeUpdate: (time: number) => void
selectedClassNum: number children?: React.ReactNode
} }
export default function VideoPlayer({ media, onTimeUpdate, selectedClassNum }: Props) { const STEP_BTN_CLASS = 'w-9 h-8 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-az-text text-xs font-mono'
const { t } = useTranslation() const ICON_BTN_CLASS = 'w-10 h-10 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-white'
export interface VideoPlayerHandle {
seek: (seconds: number) => void
getVideoElement: () => HTMLVideoElement | null
}
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({ media, onTimeUpdate, children }, ref) {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
useImperativeHandle(ref, () => ({
seek(seconds: number) {
if (videoRef.current) {
videoRef.current.currentTime = seconds
setCurrentTime(seconds)
}
},
getVideoElement() {
return videoRef.current
},
}))
const [error, setError] = useState<string | null>(null)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0)
const [muted, setMuted] = useState(false) const [muted, setMuted] = useState(false)
const token = getToken() const videoUrl = media.path.startsWith('blob:')
const videoUrl = `/api/annotations/media/${media.id}/file` ? media.path
: `/api/annotations/media/${media.id}/file`
const stepFrames = useCallback((count: number) => { const stepFrames = useCallback((count: number) => {
const video = videoRef.current const video = videoRef.current
@@ -64,48 +83,88 @@ export default function VideoPlayer({ media, onTimeUpdate, selectedClassNum }: P
} }
return ( return (
<div className="bg-black flex flex-col"> <div className="bg-black flex flex-col flex-1 min-h-0">
{error && <div className="bg-az-red/80 text-white text-xs px-2 py-1">{error}</div>}
<div className="relative flex-1 min-h-0 flex items-center justify-center">
<video <video
ref={videoRef} ref={videoRef}
src={videoUrl} src={videoUrl}
muted={muted} muted={muted}
className="w-full max-h-[50vh] object-contain" controls={false}
playsInline
className="max-w-full max-h-full object-contain"
onTimeUpdate={e => { onTimeUpdate={e => {
const t = (e.target as HTMLVideoElement).currentTime const t = (e.target as HTMLVideoElement).currentTime
setCurrentTime(t) setCurrentTime(t)
onTimeUpdate(t) onTimeUpdate(t)
}} }}
onLoadedMetadata={e => setDuration((e.target as HTMLVideoElement).duration)} onLoadedMetadata={e => {
onClick={togglePlay} setDuration((e.target as HTMLVideoElement).duration)
/> setError(null)
{/* Progress bar */}
<div
className="h-1 bg-az-border cursor-pointer"
onClick={e => {
const rect = e.currentTarget.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
if (videoRef.current) videoRef.current.currentTime = pct * duration
}} }}
> onError={() => setError(`Failed to load video (${media.name})`)}
<div className="h-full bg-az-orange" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} /> />
{children && <div className="absolute inset-0">{children}</div>}
</div> </div>
{/* Controls */} {/* Progress row: time | slider | remaining */}
<div className="flex items-center gap-1 px-2 py-1 bg-az-header text-xs"> <div className="flex items-center gap-3 bg-az-header px-4 py-1.5">
<button onClick={togglePlay} className="text-az-text hover:text-white px-1">{playing ? '⏸' : '▶'}</button> <span className="text-white text-xs font-mono tabular-nums min-w-[40px] text-right">{formatTime(currentTime)}</span>
<button onClick={stop} className="text-az-text hover:text-white px-1"></button> <input
{[1, 5, 10, 30, 60].map(n => ( type="range"
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} className="text-az-muted hover:text-white px-0.5">-{n}</button> min={0}
))} max={duration || 1}
<span className="text-az-muted mx-1">|</span> step={0.01}
{[1, 5, 10, 30, 60].map(n => ( value={currentTime}
<button key={`next-${n}`} onClick={() => stepFrames(n)} className="text-az-muted hover:text-white px-0.5">+{n}</button> onChange={e => {
))} const v = Number(e.target.value)
<div className="flex-1" /> setCurrentTime(v)
<button onClick={() => setMuted(m => !m)} className="text-az-text hover:text-white px-1"> if (videoRef.current) videoRef.current.currentTime = v
{muted ? '🔇' : '🔊'} }}
className="flex-1 accent-az-orange h-1 cursor-pointer"
style={{
background: `linear-gradient(to right, #fd7e14 0%, #fd7e14 ${(currentTime / (duration || 1)) * 100}%, #495057 ${(currentTime / (duration || 1)) * 100}%, #495057 100%)`,
}}
/>
<span className="text-white text-xs font-mono tabular-nums min-w-[40px]">-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
{/* Buttons row */}
<div className="flex items-center justify-center gap-2 bg-az-header pb-2 flex-wrap">
<button onClick={() => stepFrames(-1)} title="Previous frame" className={ICON_BTN_CLASS}>
<FaStepBackward size={14} />
</button>
<button onClick={togglePlay} title={playing ? 'Pause' : 'Play'} className="w-10 h-10 flex items-center justify-center bg-az-orange rounded hover:brightness-110 text-white">
{playing ? <FaPause size={14} /> : <FaPlay size={14} />}
</button>
<button onClick={() => stepFrames(1)} title="Next frame" className={ICON_BTN_CLASS}>
<FaStepForward size={14} />
</button>
<button onClick={stop} title="Stop" className={ICON_BTN_CLASS}>
<FaStop size={14} />
</button>
<span className="w-px h-8 bg-az-border mx-1" />
{[1, 5, 10, 30, 60].map(n => (
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} title={`-${n} frames`} className={STEP_BTN_CLASS}>
-{n}
</button>
))}
<span className="w-px h-8 bg-az-border mx-1" />
{[1, 5, 10, 30, 60].map(n => (
<button key={`next-${n}`} onClick={() => stepFrames(n)} title={`+${n} frames`} className={STEP_BTN_CLASS}>
+{n}
</button>
))}
<span className="w-px h-8 bg-az-border mx-1" />
<button onClick={() => setMuted(m => !m)} title={muted ? 'Unmute' : 'Mute'} className={ICON_BTN_CLASS}>
{muted ? <FaVolumeMute size={14} /> : <FaVolumeUp size={14} />}
</button> </button>
<span className="text-az-muted">{formatTime(currentTime)} / {formatTime(duration)}</span>
</div> </div>
</div> </div>
) )
} })
export default VideoPlayer
+24
View File
@@ -0,0 +1,24 @@
const CLASS_COLORS = [
'#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080',
]
export const FALLBACK_CLASS_NAMES = [
'Car', 'Person', 'Truck', 'Bicycle', 'Motorcycle', 'Bus',
'Animal', 'Tree', 'Building', 'Sign', 'Boat', 'Plane',
]
export function getClassColor(classNum: number): string {
const base = classNum % 20
return CLASS_COLORS[base % CLASS_COLORS.length]
}
export function getPhotoModeSuffix(classNum: number): string {
const mode = Math.floor(classNum / 20)
return mode === 1 ? ' (winter)' : mode === 2 ? ' (night)' : ''
}
export function getClassNameFallback(classNum: number): string {
const base = classNum % 20
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
}
-44
View File
@@ -1,44 +0,0 @@
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>
)
}
-97
View File
@@ -1,97 +0,0 @@
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>
)
}
-76
View File
@@ -1,76 +0,0 @@
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
}
@@ -1,74 +0,0 @@
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">&#215;</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>
)
}
+19 -167
View File
@@ -1,183 +1,35 @@
import { useRef, useEffect, useState } from 'react' import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet'
import { MapContainer, TileLayer, Marker, Popup, Polyline, Rectangle, useMap, useMapEvents } from 'react-leaflet' import type { Waypoint } from '../../types'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import 'leaflet-polylinedecorator' import L from 'leaflet'
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'
interface MapEventsProps { const icon = L.divIcon({ className: 'bg-az-orange rounded-full w-3 h-3 border border-white', iconSize: [12, 12] })
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 {
points: FlightPoint[] waypoints: Waypoint[]
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({ export default function FlightMap({ waypoints }: Props) {
points, currentPosition, rectangles, setRectangles, const center: [number, number] = waypoints.length > 0
rectangleColor, actionMode, onAddPoint, onUpdatePoint, onRemovePoint, ? [waypoints[0].latitude, waypoints[0].longitude]
onAltitudeChange, onMetaChange, onPolylineClick, onPositionChange, onMapMove, : [50.45, 30.52]
}: 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)
useEffect(() => { setDraggablePoints(points) }, [points]) const positions = waypoints
.sort((a, b) => a.order - b.order)
function ClickHandler() { .map(w => [w.latitude, w.longitude] as [number, number])
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 (
<div className="flex-1 relative" ref={containerRef}> <MapContainer center={center} zoom={13} className="h-full w-full" key={center.join(',')}>
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
<ClickHandler />
<TileLayer <TileLayer
url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite} attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
attribution={mapType === 'classic' ? '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>' : 'Satellite'} url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/> />
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} /> {waypoints.map(wp => (
<SetView center={currentPosition} /> <Marker key={wp.id} position={[wp.latitude, wp.longitude]} icon={icon}>
<Popup>{wp.name}</Popup>
{movingPoint && <MiniMap pointPosition={movingPoint} mapType={mapType} />}
{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> </Marker>
)}
{rectangles.map(rect => (
<Rectangle key={rect.id} bounds={rect.bounds} pathOptions={{ color: rect.color }} />
))} ))}
{positions.length > 1 && <Polyline positions={positions} color="#fd7e14" weight={2} />}
<DrawControl color={rectangleColor} actionMode={actionMode} rectangles={rectangles} setRectangles={setRectangles} />
</MapContainer> </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>
) )
} }
-150
View File
@@ -1,150 +0,0 @@
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>
)
}
+102 -266
View File
@@ -1,82 +1,46 @@
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 FlightListSidebar from './FlightListSidebar' import type { Flight, Waypoint, Aircraft } from '../../types'
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 [deleteId, setDeleteId] = useState<string | null>(null) const [waypoints, setWaypoints] = useState<Waypoint[]>([])
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 [aircraft, setAircraft] = useState<AircraftParams | null>(null) const [newName, setNewName] = useState('')
const [points, setPoints] = useState<FlightPoint[]>([]) const leftPanel = useResizablePanel(200, 150, 350)
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) { setPoints([]); return } if (!selectedFlight) { setWaypoints([]); return }
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`) api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).then(setWaypoints).catch(() => {})
.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<{ lat: number; lon: number; satellites: number; status: string }>(`/api/flights/${selectedFlight.id}/live-gps`, (data) => setLiveGps(data)) return createSSE(`/api/flights/${selectedFlight.id}/live-gps`, (data: any) => setLiveGps(data))
}, [selectedFlight, mode]) }, [selectedFlight, mode])
useEffect(() => { const handleCreate = async () => {
if (!aircraft || points.length < 2) { setCalculatedPointInfo([{ bat: 100, time: 0 }]); return } if (!newName.trim()) return
calculateAllPoints(points, aircraft, initialAltitude).then(setCalculatedPointInfo) await api.post('/api/flights', { name: newName.trim() })
}, [points, aircraft, initialAltitude]) setNewName('')
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)
@@ -84,203 +48,105 @@ export default function FlightsPage() {
refreshFlights() refreshFlights()
} }
const addPoint = useCallback((lat: number, lng: number) => { const handleAddWaypoint = async () => {
const pt: FlightPoint = { id: newGuid(), position: { lat, lng }, altitude: initialAltitude, meta: [PURPOSES[0].value, PURPOSES[1].value] }
setPoints(prev => [...prev, pt])
}, [initialAltitude])
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 }
}
})
if (closestIdx !== -1) {
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 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) })
}
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 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`, { await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
name: `Point ${i + 1}`, name: `Point ${waypoints.length}`,
latitude: points[i].position.lat, latitude: 50.45, longitude: 30.52, order: waypoints.length,
longitude: points[i].position.lng, })
order: i, const wps = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
}).catch(() => {}) setWaypoints(wps)
}
} }
const handleLocationSearch = () => { const handleDeleteWaypoint = async (wpId: string) => {
const coords = parseCoordinates(locationInput) if (!selectedFlight) return
if (coords) setCurrentPosition(coords) await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wpId}`)
setWaypoints(prev => prev.filter(w => w.id !== wpId))
} }
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">
<FlightListSidebar {/* Flight list sidebar */}
flights={flights} <div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
selectedFlight={selectedFlight} <div className="p-2 border-b border-az-border">
onSelect={selectFlight} <div className="flex gap-1">
onCreate={handleCreateFlight} <input
onDelete={(id) => setDeleteId(id)} value={newName}
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>
{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">&#187;</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'}`}>&#9679;</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'}`}>&#9635;</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'}`}>&#9635;</button>
</div> </div>
) : ( </div>
<div className="w-80 bg-az-panel border-r border-az-border flex flex-col shrink-0"> <div className="flex-1 overflow-y-auto">
<div className="flex border-b border-az-border items-stretch"> {flights.map(f => (
<button onClick={() => setMode('params')} <div
className={`flex-1 py-1.5 text-[10px] ${mode === 'params' ? 'bg-az-bg text-white' : 'text-az-muted'}`}> 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 */}
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
{/* Left params panel */}
{selectedFlight && (
<div className="w-64 bg-az-panel border-r border-az-border flex flex-col shrink-0 overflow-y-auto">
<div className="flex border-b border-az-border">
<button
onClick={() => setMode('params')}
className={`flex-1 py-1.5 text-xs ${mode === 'params' ? 'bg-az-bg text-white' : 'text-az-muted'}`}
>
{t('flights.params')} {t('flights.params')}
</button> </button>
<button onClick={() => setMode('gps')} <button
className={`flex-1 py-1.5 text-[10px] ${mode === 'gps' ? 'bg-az-bg text-white' : 'text-az-muted'}`}> onClick={() => setMode('gps')}
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">&#171;</button>
</div> </div>
{mode === 'params' && ( {mode === 'params' && (
<FlightParamsPanel <div className="p-2 space-y-2 text-xs">
points={points} calculatedPointInfo={calculatedPointInfo} aircrafts={aircrafts} <div>
initialAltitude={initialAltitude} actionMode={actionMode} <label className="text-az-muted block mb-0.5">{t('flights.aircraft')}</label>
wind={wind} locationInput={locationInput} currentPosition={currentPosition} <select className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text">
totalDistance={totalDist} totalTime={totalTimeStr} batteryStatus={batteryStatus} {aircrafts.map(a => <option key={a.id} value={a.id}>{a.model}</option>)}
onInitialAltitudeChange={setInitialAltitude} onActionModeChange={setActionMode} </select>
onWindChange={setWind} onLocationInputChange={setLocationInput} </div>
onLocationSearch={handleLocationSearch} <div>
onReorderPoints={setPoints} onEditPoint={openEditDialog} onRemovePoint={removePoint} <label className="text-az-muted block mb-0.5">{t('flights.height')}</label>
onSave={handleSave} onUpload={handleImport} onEditJson={handleEditJson} onExport={handleExport} <input type="number" className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" defaultValue={100} />
/> </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' && (
@@ -306,46 +172,16 @@ export default function FlightsPage() {
</div> </div>
)} )}
<FlightMap {/* Map view */}
points={points} calculatedPointInfo={calculatedPointInfo} <div className="flex-1 relative">
currentPosition={currentPosition} rectangles={rectangles} setRectangles={setRectangles} <FlightMap waypoints={waypoints} />
rectangleColor={actionMode === 'workArea' ? 'green' : 'red'} </div>
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={handleDeleteFlight} onConfirm={handleDelete}
onCancel={() => setDeleteId(null)} onCancel={() => setDeleteId(null)}
/> />
</div> </div>
-53
View File
@@ -1,53 +0,0 @@
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>
)
}
-87
View File
@@ -1,87 +0,0 @@
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>
)
}
-32
View File
@@ -1,32 +0,0 @@
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>
)
}
-62
View File
@@ -1,62 +0,0 @@
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">&#9998;</button>
<button onClick={() => onRemove(point.id)} className="hover:text-az-red">&#215;</button>
</span>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)
}
-32
View File
@@ -1,32 +0,0 @@
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>
)
}
-146
View File
@@ -1,146 +0,0 @@
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,
}
}
-22
View File
@@ -1,22 +0,0 @@
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],
})
-58
View File
@@ -1,58 +0,0 @@
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
+1 -50
View File
@@ -33,56 +33,7 @@
"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",
+1 -50
View File
@@ -33,56 +33,7 @@
"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": "Анотації",
-4
View File
@@ -1,4 +0,0 @@
/// <reference types="vite/client" />
declare module '*.css'
declare module 'leaflet-polylinedecorator'
+5 -1
View File
@@ -1 +1,5 @@
{"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"} <<<<<<< Updated upstream
{"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/vite-env.d.ts","./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.jsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/components/annotationcontrols.jsx","./src/features/annotations/components/annotationlist.jsx","./src/features/annotations/components/annotationmain.jsx","./src/features/annotations/components/canvaseditor.jsx","./src/features/annotations/components/detection.jsx","./src/features/annotations/components/detectionclasslist.jsx","./src/features/annotations/components/detectioncontainer.jsx","./src/features/annotations/components/medialist.jsx","./src/features/annotations/components/videoplayer.jsx","./src/features/annotations/constants/detectiontypes.js","./src/features/annotations/icons/cleanicon.jsx","./src/features/annotations/icons/deleteicon.jsx","./src/features/annotations/icons/nexticon.jsx","./src/features/annotations/icons/pauseicon.jsx","./src/features/annotations/icons/playicon.jsx","./src/features/annotations/icons/previousicon.jsx","./src/features/annotations/icons/saveicon.jsx","./src/features/annotations/icons/stopicon.jsx","./src/features/annotations/models/detectionclass.js","./src/features/annotations/services/annotationservice.js","./src/features/annotations/services/backendservice.js","./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"}
>>>>>>> Stashed changes