mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 21:51:11 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff522b0821 | |||
| dfcdc26630 | |||
| 60d77d0f29 | |||
| f754afff46 |
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider, ProtectedRoute } from './auth'
|
import { AuthProvider, ProtectedRoute } from './auth'
|
||||||
import { Header, FlightProvider } from './components'
|
import { Header, FlightProvider, SavedAnnotationsProvider } from './components'
|
||||||
import { LoginPage } from './features/login'
|
import { LoginPage } from './features/login'
|
||||||
import { FlightsPage } from './features/flights'
|
import { FlightsPage } from './features/flights'
|
||||||
import { AnnotationsPage } from './features/annotations'
|
import { AnnotationsPage } from './features/annotations'
|
||||||
@@ -18,6 +18,7 @@ export default function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<FlightProvider>
|
<FlightProvider>
|
||||||
|
<SavedAnnotationsProvider>
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
@@ -31,6 +32,7 @@ export default function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</SavedAnnotationsProvider>
|
||||||
</FlightProvider>
|
</FlightProvider>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,29 @@ export function __resetBootstrapInflightForTests(): void {
|
|||||||
bootstrapInflight = null
|
bootstrapInflight = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dev-only escape hatch: `VITE_DEV_AUTH_BYPASS=true` skips the backend round
|
||||||
|
// trip and injects a fake admin user so the SPA renders authenticated. Lives
|
||||||
|
// in this file so the bypass is gated by the same effect that owns auth state;
|
||||||
|
// the import.meta.env check is also tree-shaken out of production builds when
|
||||||
|
// the flag is unset at build time.
|
||||||
|
const DEV_BYPASS_USER: AuthUser = {
|
||||||
|
id: 'dev-bypass',
|
||||||
|
email: 'dev@azaion.local',
|
||||||
|
name: 'Dev Bypass',
|
||||||
|
role: 'admin',
|
||||||
|
// Permission codes are short identifiers checked via hasPermission(code) —
|
||||||
|
// currently used by the Header to gate the nav tabs (FL, ANN, DATASET, ADM).
|
||||||
|
permissions: ['FL', 'ANN', 'DATASET', 'ADM'],
|
||||||
|
}
|
||||||
|
|
||||||
async function runBootstrap(): Promise<AuthUser | null> {
|
async function runBootstrap(): Promise<AuthUser | null> {
|
||||||
|
// Gated on import.meta.env.DEV so a leaked VITE_DEV_AUTH_BYPASS=true in a
|
||||||
|
// production build cannot grant admin access. Vite tree-shakes the entire
|
||||||
|
// branch when DEV is false at build time.
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEV_AUTH_BYPASS === 'true') {
|
||||||
|
setToken('dev-bypass-token')
|
||||||
|
return DEV_BYPASS_USER
|
||||||
|
}
|
||||||
// POST refresh with credentials — the whole point of the consolidation. Goes
|
// POST refresh with credentials — the whole point of the consolidation. Goes
|
||||||
// through fetch() directly (not api.post) because api.post does not thread
|
// through fetch() directly (not api.post) because api.post does not thread
|
||||||
// credentials:'include'; widening api.post would change CORS posture for
|
// credentials:'include'; widening api.post would change CORS posture for
|
||||||
|
|||||||
@@ -22,3 +22,11 @@ export function getClassNameFallback(classNum: number): string {
|
|||||||
const base = classNum % 20
|
const base = classNum % 20
|
||||||
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
|
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const h = hex.replace('#', '')
|
||||||
|
const r = parseInt(h.slice(0, 2), 16)
|
||||||
|
const g = parseInt(h.slice(2, 4), 16)
|
||||||
|
const b = parseInt(h.slice(4, 6), 16)
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export {
|
|||||||
getClassColor,
|
getClassColor,
|
||||||
getPhotoModeSuffix,
|
getPhotoModeSuffix,
|
||||||
getClassNameFallback,
|
getClassNameFallback,
|
||||||
|
hexToRgba,
|
||||||
FALLBACK_CLASS_NAMES,
|
FALLBACK_CLASS_NAMES,
|
||||||
} from './classColors'
|
} from './classColors'
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
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, endpoints } from '../api'
|
import { api, endpoints } from '../api'
|
||||||
// classColors lives under 06_annotations until F3 moves it to its own home.
|
// classColors lives under 06_annotations until F3 moves it to its own home.
|
||||||
// Importing through the 06_annotations barrel would create a cycle
|
// Importing through the 06_annotations barrel would create a cycle
|
||||||
@@ -60,44 +58,72 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
|||||||
}
|
}
|
||||||
}, [classes, photoMode, selectedClassNum, onSelect])
|
}, [classes, photoMode, selectedClassNum, onSelect])
|
||||||
|
|
||||||
|
const modeClasses = classes.filter(c => c.photoMode === photoMode)
|
||||||
|
|
||||||
const modes = [
|
const modes = [
|
||||||
{ value: 0, label: t('annotations.regular'), icon: <MdOutlineWbSunny />, activeClass: 'bg-az-orange text-white', iconColor: 'text-az-orange' },
|
{ value: 0, label: t('annotations.regular') },
|
||||||
{ value: 20, label: t('annotations.winter'), icon: <FaRegSnowflake />, activeClass: 'bg-az-blue text-white', iconColor: 'text-az-blue' },
|
{ value: 20, label: t('annotations.winter') },
|
||||||
{ value: 40, label: t('annotations.night'), icon: <MdOutlineNightlightRound />, activeClass: 'bg-purple-600 text-white', iconColor: 'text-purple-400' },
|
{ value: 40, label: t('annotations.night') },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-az-border p-2">
|
<div className="border-t border-border-hair">
|
||||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div>
|
{/* Section header */}
|
||||||
<div className="space-y-0.5 max-h-48 overflow-y-auto mb-2">
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
|
||||||
{classes.filter(c => c.photoMode === photoMode).map((c, i) => (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<span className="sect-head">{t('annotations.classes')}</span>
|
||||||
key={c.id}
|
<span className="mono text-[10px] text-text-muted">{modeClasses.length.toString().padStart(2, '0')}</span>
|
||||||
onClick={() => onSelect(c.id)}
|
|
||||||
className={`w-full flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs text-left ${
|
|
||||||
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: getClassColor(c.id) }} />
|
|
||||||
<span className="text-az-muted">{i + 1}.</span>
|
|
||||||
<span className="truncate">{c.name}</span>
|
|
||||||
<span className="text-az-muted ml-auto">{c.shortName}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.photoMode')}</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
|
||||||
|
{/* Column headers */}
|
||||||
|
<div className="grid grid-cols-[28px_1fr_auto] px-3 h-6 items-center border-b border-border-hair gap-2">
|
||||||
|
<span className="micro">{t('annotations.colNum')}</span>
|
||||||
|
<span className="micro">{t('annotations.colName')}</span>
|
||||||
|
<span className="micro">{t('annotations.colKey')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class rows */}
|
||||||
|
<div>
|
||||||
|
{modeClasses.map((c, i) => {
|
||||||
|
const isActive = selectedClassNum === c.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onSelect(c.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onSelect(c.id) }}
|
||||||
|
className={`class-row${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="swatch" style={{ background: getClassColor(c.id) }} />
|
||||||
|
<span className={`truncate${isActive ? ' text-text-primary font-medium' : ' text-text-primary'}`}>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
<span className="kbd">{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PhotoMode segmented control */}
|
||||||
|
<div className="p-3 border-t border-border-hair">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="micro">{t('annotations.photoMode')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="seg" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', width: '100%' }}>
|
||||||
{modes.map(m => (
|
{modes.map(m => (
|
||||||
<button
|
<button
|
||||||
key={m.value}
|
key={m.value}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn${photoMode === m.value ? ' active' : ''}`}
|
||||||
onClick={() => onPhotoModeChange(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}
|
{m.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export { default as HelpModal } from './HelpModal'
|
|||||||
export { default as ConfirmDialog } from './ConfirmDialog'
|
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||||
export { default as DetectionClasses } from './DetectionClasses'
|
export { default as DetectionClasses } from './DetectionClasses'
|
||||||
export { FlightProvider, useFlight } from './FlightContext'
|
export { FlightProvider, useFlight } from './FlightContext'
|
||||||
|
export { SavedAnnotationsProvider, useSavedAnnotations } from './SavedAnnotationsContext'
|
||||||
|
|||||||
@@ -1,38 +1,108 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useResizablePanel } from '../../hooks'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
import MediaList from './MediaList'
|
import MediaList from './MediaList'
|
||||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||||
|
import Scrubber, { type ScrubberMark } from './Scrubber'
|
||||||
import { DetectionClasses, useFlight } from '../../components'
|
import { DetectionClasses, useFlight } from '../../components'
|
||||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||||
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
||||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
||||||
import { captureThumbnails } from './thumbnail'
|
import { captureThumbnails } from './thumbnail'
|
||||||
|
import { formatTime, formatTicks, parseAnnotationTime } from './time'
|
||||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
import type { Media, AnnotationListItem, Detection } from '../../types'
|
||||||
|
|
||||||
|
const FRAME_STEPS = [1, 5, 10, 30, 60]
|
||||||
|
|
||||||
|
const FAKE_LOG_LINES = [
|
||||||
|
'[tile 04/16] 2 candidates',
|
||||||
|
'[tile 05/16] 1 candidate (conf 0.94)',
|
||||||
|
'[filter] min_conf=0.25…',
|
||||||
|
]
|
||||||
|
|
||||||
export default function AnnotationsPage() {
|
export default function AnnotationsPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
|
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
|
||||||
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
|
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
|
||||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||||
const [photoMode, setPhotoMode] = useState(0)
|
const [photoMode, setPhotoMode] = useState(0)
|
||||||
const [detections, setDetections] = useState<Detection[]>([])
|
const [detections, setDetections] = useState<Detection[]>([])
|
||||||
const leftPanel = useResizablePanel(250, 200, 400)
|
const [zoom, setZoom] = useState(1)
|
||||||
const rightPanel = useResizablePanel(200, 150, 350)
|
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [volume, setVolume] = useState(0.62)
|
||||||
|
const [muted, setMuted] = useState(false)
|
||||||
|
const [aiDetecting, setAiDetecting] = useState(false)
|
||||||
|
const [aiLog, setAiLog] = useState<string[]>([])
|
||||||
|
const [aiProgress, setAiProgress] = useState(0)
|
||||||
|
const aiStartRef = useRef<number>(0)
|
||||||
|
const aiCloseTimerRef = useRef<number | null>(null)
|
||||||
|
const [aiElapsed, setAiElapsed] = useState(0)
|
||||||
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
||||||
const canvasRef = useRef<CanvasEditorHandle>(null)
|
const canvasRef = useRef<CanvasEditorHandle>(null)
|
||||||
const { addMany } = useSavedAnnotations()
|
const { addMany } = useSavedAnnotations()
|
||||||
const { selectedFlight } = useFlight()
|
const { selectedFlight } = useFlight()
|
||||||
|
|
||||||
|
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetections([])
|
setDetections([])
|
||||||
setSelectedAnnotation(null)
|
setSelectedAnnotation(null)
|
||||||
setCurrentTime(0)
|
setCurrentTime(0)
|
||||||
|
setDuration(0)
|
||||||
|
setIsPlaying(false)
|
||||||
|
setMuted(false)
|
||||||
}, [selectedMedia])
|
}, [selectedMedia])
|
||||||
|
|
||||||
|
// Push the page's initial volume into the <video> element once the player
|
||||||
|
// is mounted — otherwise the slider shows 62% while audio plays at 100%.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMedia || !isVideo) return
|
||||||
|
videoPlayerRef.current?.setVolume(volume)
|
||||||
|
// Only on media change — subsequent slider drags push via onVolumeChange.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedMedia, isVideo])
|
||||||
|
|
||||||
|
// AI detection fake-log progress
|
||||||
|
useEffect(() => {
|
||||||
|
if (!aiDetecting) return
|
||||||
|
aiStartRef.current = performance.now()
|
||||||
|
setAiElapsed(0)
|
||||||
|
setAiLog([])
|
||||||
|
setAiProgress(0)
|
||||||
|
let i = 0
|
||||||
|
const logTimer = window.setInterval(() => {
|
||||||
|
if (i < FAKE_LOG_LINES.length) {
|
||||||
|
setAiLog(prev => [...prev, FAKE_LOG_LINES[i]])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}, 700)
|
||||||
|
const tickTimer = window.setInterval(() => {
|
||||||
|
setAiElapsed((performance.now() - aiStartRef.current) / 1000)
|
||||||
|
setAiProgress(p => Math.min(0.95, p + 0.04))
|
||||||
|
}, 100)
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(logTimer)
|
||||||
|
window.clearInterval(tickTimer)
|
||||||
|
}
|
||||||
|
}, [aiDetecting])
|
||||||
|
|
||||||
|
const scrubberMarks = useMemo<ScrubberMark[]>(() => {
|
||||||
|
return annotations
|
||||||
|
.map(a => {
|
||||||
|
const sec = parseAnnotationTime(a.time)
|
||||||
|
if (sec == null) return null
|
||||||
|
const first = a.detections[0]
|
||||||
|
return { time: sec, color: first ? getClassColor(first.classNum) : '#9AA4B2' }
|
||||||
|
})
|
||||||
|
.filter((m): m is ScrubberMark => m !== null)
|
||||||
|
}, [annotations])
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!selectedMedia || !detections.length) return
|
if (!selectedMedia || !detections.length) return
|
||||||
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
||||||
@@ -108,7 +178,6 @@ export default function AnnotationsPage() {
|
|||||||
txtA.click()
|
txtA.click()
|
||||||
URL.revokeObjectURL(txtUrl)
|
URL.revokeObjectURL(txtUrl)
|
||||||
|
|
||||||
// Build the image: video frame or image with rectangles drawn
|
|
||||||
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
|
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
|
||||||
let w = 0, h = 0
|
let w = 0, h = 0
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -181,11 +250,10 @@ export default function AnnotationsPage() {
|
|||||||
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
||||||
setSelectedAnnotation(ann)
|
setSelectedAnnotation(ann)
|
||||||
setDetections(ann.detections)
|
setDetections(ann.detections)
|
||||||
if (ann.time) {
|
const sec = parseAnnotationTime(ann.time)
|
||||||
const parts = ann.time.split(':').map(Number)
|
if (sec != null) {
|
||||||
const seconds = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
|
videoPlayerRef.current?.seek(sec)
|
||||||
videoPlayerRef.current?.seek(seconds)
|
setCurrentTime(sec)
|
||||||
setCurrentTime(seconds)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -193,20 +261,68 @@ export default function AnnotationsPage() {
|
|||||||
setDetections(dets)
|
setDetections(dets)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
const handleAiDetect = useCallback(async () => {
|
||||||
|
if (!selectedMedia || aiDetecting) return
|
||||||
function formatTicks(seconds: number): string {
|
if (aiCloseTimerRef.current != null) {
|
||||||
const h = Math.floor(seconds / 3600)
|
window.clearTimeout(aiCloseTimerRef.current)
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
aiCloseTimerRef.current = null
|
||||||
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')}`
|
|
||||||
}
|
}
|
||||||
|
setAiDetecting(true)
|
||||||
|
try {
|
||||||
|
await api.post(endpoints.detect.media(selectedMedia.id))
|
||||||
|
} catch {
|
||||||
|
// banner stays visible briefly; sidebar SSE refresh will pick up results
|
||||||
|
} finally {
|
||||||
|
setAiProgress(1)
|
||||||
|
aiCloseTimerRef.current = window.setTimeout(() => {
|
||||||
|
aiCloseTimerRef.current = null
|
||||||
|
setAiDetecting(false)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}, [selectedMedia, aiDetecting])
|
||||||
|
|
||||||
|
// Clear any pending AI-banner close timer on unmount.
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (aiCloseTimerRef.current != null) {
|
||||||
|
window.clearTimeout(aiCloseTimerRef.current)
|
||||||
|
aiCloseTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const togglePlay = () => { videoPlayerRef.current?.toggle() }
|
||||||
|
const stepFrames = (n: number) => { videoPlayerRef.current?.frameStep(n) }
|
||||||
|
const seekRel = (sec: number) => {
|
||||||
|
const p = videoPlayerRef.current
|
||||||
|
if (!p) return
|
||||||
|
p.seek(Math.max(0, Math.min(p.getDuration(), p.getCurrentTime() + sec)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVolumeChange = (v: number) => {
|
||||||
|
setVolume(v)
|
||||||
|
videoPlayerRef.current?.setVolume(v)
|
||||||
|
}
|
||||||
|
const toggleMute = () => {
|
||||||
|
// VideoPlayer.toggleMute() fires onMutedChange, which updates `muted` —
|
||||||
|
// don't flip parent state independently or the two desync (e.g. M-key
|
||||||
|
// shortcut already routed via onMutedChange).
|
||||||
|
videoPlayerRef.current?.toggleMute()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dims = (() => {
|
||||||
|
const v = videoPlayerRef.current?.getVideoElement()
|
||||||
|
if (!v || !v.videoWidth) return null
|
||||||
|
return { w: v.videoWidth, h: v.videoHeight }
|
||||||
|
})()
|
||||||
|
const fps = videoPlayerRef.current?.getFrameRate() ?? 30
|
||||||
|
const currentFrame = isVideo ? Math.floor(currentTime * fps) : 0
|
||||||
|
const totalFrames = isVideo ? Math.floor(duration * fps) : 0
|
||||||
|
|
||||||
|
const detectionsLabel = `${detections.length} det${detections.length !== 1 ? 's' : ''}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* Left panel */}
|
{/* LEFT SIDEBAR */}
|
||||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
<div style={{ width: 232 }} className="bg-surface-1 flex flex-col shrink-0 border-r border-border-hair">
|
||||||
<MediaList
|
<MediaList
|
||||||
selectedMedia={selectedMedia}
|
selectedMedia={selectedMedia}
|
||||||
onSelect={setSelectedMedia}
|
onSelect={setSelectedMedia}
|
||||||
@@ -219,41 +335,46 @@ export default function AnnotationsPage() {
|
|||||||
onPhotoModeChange={setPhotoMode}
|
onPhotoModeChange={setPhotoMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
{/* CENTER */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 bg-surface-0">
|
||||||
{/* Center - video/canvas */}
|
{/* Canvas top bar */}
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="h-9 flex items-center gap-3 px-4 border-b border-border-hair bg-surface-1 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="sect-head">{t('annotations.canvas')}</span>
|
||||||
{selectedMedia && (
|
{selectedMedia && (
|
||||||
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
|
<>
|
||||||
<button
|
<span className="mono text-[11px] text-text-muted">{selectedMedia.name}</span>
|
||||||
onClick={handleSave}
|
{dims && (
|
||||||
disabled={!detections.length}
|
<span className="mono text-[10px] px-1.5 py-0.5 border border-border-hair text-text-secondary">
|
||||||
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"
|
{dims.w}×{dims.h} · {fps} FPS
|
||||||
>
|
</span>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<span className="micro">{t('annotations.zoom')}</span>
|
||||||
|
<span className="mono text-[11px] text-text-primary">{Math.round(zoom * 100)}%</span>
|
||||||
|
<span className="mx-2 h-4 w-px bg-border-hair" />
|
||||||
|
<span className="micro">{t('annotations.cursor')}</span>
|
||||||
|
<span className="mono text-[11px] text-text-primary">
|
||||||
|
{cursor ? `${cursor.x.toFixed(3)}, ${cursor.y.toFixed(3)}` : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="mx-2 h-4 w-px bg-border-hair" />
|
||||||
|
<span className="mono text-[11px] text-text-secondary">{detectionsLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas area */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
{selectedMedia && isVideo && (
|
{selectedMedia && isVideo && (
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
ref={videoPlayerRef}
|
ref={videoPlayerRef}
|
||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
onTimeUpdate={setCurrentTime}
|
onTimeUpdate={setCurrentTime}
|
||||||
|
onPlayingChange={setIsPlaying}
|
||||||
|
onDurationChange={setDuration}
|
||||||
|
onMutedChange={setMuted}
|
||||||
>
|
>
|
||||||
<CanvasEditor
|
<CanvasEditor
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
@@ -264,6 +385,8 @@ export default function AnnotationsPage() {
|
|||||||
selectedClassNum={selectedClassNum}
|
selectedClassNum={selectedClassNum}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCursorChange={(x, y) => setCursor({ x, y })}
|
||||||
/>
|
/>
|
||||||
</VideoPlayer>
|
</VideoPlayer>
|
||||||
)}
|
)}
|
||||||
@@ -277,18 +400,178 @@ export default function AnnotationsPage() {
|
|||||||
selectedClassNum={selectedClassNum}
|
selectedClassNum={selectedClassNum}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCursorChange={(x, y) => setCursor({ x, y })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!selectedMedia && (
|
{!selectedMedia && (
|
||||||
<div className="flex-1 flex items-center justify-center text-az-muted text-sm">
|
<div className="absolute inset-0 flex items-center justify-center text-text-muted text-sm">
|
||||||
Select a media file to start
|
{t('annotations.selectMedia')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Detection floating banner */}
|
||||||
|
{aiDetecting && (
|
||||||
|
<div className="absolute top-6 right-6 ai-banner px-3 py-2 w-72">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="live-dot" />
|
||||||
|
<span className="micro text-accent-cyan">{t('annotations.detectInProgress')}</span>
|
||||||
|
<span className="ml-auto mono text-[10px] text-text-muted">{aiElapsed.toFixed(1)}s</span>
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[10px] space-y-0.5 text-text-secondary">
|
||||||
|
{aiLog.map((line, i) => <div key={i}>{line}</div>)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-[2px] bg-black/40 overflow-hidden">
|
||||||
|
<div style={{ height: '100%', width: `${aiProgress * 100}%`, background: 'var(--accent-cyan)' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel */}
|
{/* Scrubber + Controls */}
|
||||||
<div onMouseDown={rightPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
{selectedMedia && isVideo && (
|
||||||
<div style={{ width: rightPanel.width }} className="bg-az-panel border-l border-az-border flex flex-col shrink-0">
|
<div className="border-t border-border-hair bg-surface-1 shrink-0">
|
||||||
|
<div className="px-4 pt-3 pb-2">
|
||||||
|
<Scrubber
|
||||||
|
current={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
marks={scrubberMarks}
|
||||||
|
onSeek={t => { videoPlayerRef.current?.seek(t); setCurrentTime(t) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3 flex items-center gap-1.5 min-w-0 whitespace-nowrap overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1 p-1 border border-border-hair rounded-[2px]">
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.previousMedia')}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.back5s')} onClick={() => seekRel(-5)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M11 18V6l-8.5 6zM22 18V6l-8.5 6z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ibtn"
|
||||||
|
title={isPlaying ? t('annotations.pause') : t('annotations.play')}
|
||||||
|
onClick={togglePlay}
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
background: isPlaying ? 'rgba(255,157,61,0.12)' : 'transparent',
|
||||||
|
color: isPlaying ? 'var(--accent-amber)' : undefined,
|
||||||
|
borderColor: isPlaying ? 'var(--accent-amber)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying
|
||||||
|
? <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>
|
||||||
|
: <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>}
|
||||||
|
</button>
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.forward5s')} onClick={() => seekRel(5)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M13 6v12l8.5-6zM2 6v12l8.5-6z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.nextMedia')}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="micro">{t('annotations.frameStep')}</span>
|
||||||
|
<div className="flex items-center gap-1 p-1 border border-border-hair rounded-[2px]">
|
||||||
|
{FRAME_STEPS.map(n => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => stepFrames(n)}
|
||||||
|
className="ibtn mono"
|
||||||
|
style={{ width: 30, height: 28, fontSize: 10, border: 0, background: 'transparent', letterSpacing: 0 }}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!detections.length}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
|
||||||
|
{t('annotations.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => canvasRef.current?.deleteSelected()}
|
||||||
|
disabled={!detections.length}
|
||||||
|
className="btn btn-danger-ghost"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||||
|
{t('annotations.delete')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => canvasRef.current?.deleteAll()}
|
||||||
|
disabled={!detections.length}
|
||||||
|
className="btn btn-danger-ghost"
|
||||||
|
title={t('annotations.deleteAllTitle')}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11l4 6M14 11l-4 6"/></svg>
|
||||||
|
{t('annotations.deleteAll')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAiDetect}
|
||||||
|
disabled={!selectedMedia || aiDetecting}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7V3h4"/><path d="M17 3h4v4"/><path d="M21 17v4h-4"/><path d="M7 21H3v-4"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/></svg>
|
||||||
|
{t('annotations.detect')}
|
||||||
|
<span className="ml-1 mono opacity-70" style={{ fontSize: 9 }}>[R]</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28 }} title={t('annotations.mute')} onClick={toggleMute}>
|
||||||
|
{muted
|
||||||
|
? <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.21.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.95 8.95 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.17v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
|
||||||
|
: <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/></svg>}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="vol"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={Math.round(volume * 100)}
|
||||||
|
onChange={e => onVolumeChange(Number(e.target.value) / 100)}
|
||||||
|
/>
|
||||||
|
<span className="mono text-[10px] text-text-muted" style={{ width: 24 }}>{Math.round(volume * 100)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<div className="px-4 h-7 flex items-center border-t border-border-hair bg-surface-0">
|
||||||
|
<span className="mono text-[11px] text-text-primary">{formatTime(currentTime, true)}</span>
|
||||||
|
<span className="mono text-[11px] mx-1.5 text-text-muted">/</span>
|
||||||
|
<span className="mono text-[11px] text-text-secondary">{formatTime(duration, true)}</span>
|
||||||
|
<span className="mx-3 h-4 w-px bg-border-hair" />
|
||||||
|
<span className="micro">{t('annotations.frame')}</span>
|
||||||
|
<span className="mono text-[11px] ml-1.5 text-text-primary">{currentFrame} / {totalFrames}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo-only controls row (save/delete/AI detect) */}
|
||||||
|
{selectedMedia && !isVideo && (
|
||||||
|
<div className="border-t border-border-hair bg-surface-1 shrink-0 px-4 py-2 flex items-center gap-3">
|
||||||
|
<button onClick={handleSave} disabled={!detections.length} className="btn btn-secondary">{t('annotations.save')}</button>
|
||||||
|
<button onClick={() => canvasRef.current?.deleteSelected()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.delete')}</button>
|
||||||
|
<button onClick={() => canvasRef.current?.deleteAll()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.deleteAll')}</button>
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
<button onClick={handleAiDetect} disabled={!selectedMedia || aiDetecting} className="btn btn-primary">{t('annotations.detect')}</button>
|
||||||
|
<span className="ml-auto mono text-[11px] text-text-muted">{detectionsLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT SIDEBAR */}
|
||||||
|
<div style={{ width: 208 }} className="bg-surface-1 flex flex-col shrink-0 border-l border-border-hair">
|
||||||
<AnnotationsSidebar
|
<AnnotationsSidebar
|
||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FaDownload } from 'react-icons/fa'
|
import { FaDownload } from 'react-icons/fa'
|
||||||
import { api, createSSE, endpoints } from '../../api'
|
import { api, createSSE, endpoints } from '../../api'
|
||||||
import { getClassColor } from '../../class-colors'
|
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
|
||||||
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,10 +14,46 @@ interface Props {
|
|||||||
onDownload?: (ann: AnnotationListItem) => void
|
onDownload?: (ann: AnnotationListItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRowGradient(ann: AnnotationListItem): string {
|
||||||
|
if (ann.detections.length === 0) {
|
||||||
|
return 'linear-gradient(90deg, rgba(221,221,221,0.10), rgba(221,221,221,0.04))'
|
||||||
|
}
|
||||||
|
if (ann.detections.length === 1) {
|
||||||
|
const c = getClassColor(ann.detections[0].classNum)
|
||||||
|
return `linear-gradient(90deg, ${hexToRgba(c, 0.55)} 0%, ${hexToRgba(c, 0.10)} 60%, transparent 100%)`
|
||||||
|
}
|
||||||
|
const n = ann.detections.length
|
||||||
|
const bandWidth = 100 / n
|
||||||
|
const stops: string[] = []
|
||||||
|
ann.detections.forEach((d, i) => {
|
||||||
|
const c = getClassColor(d.classNum)
|
||||||
|
const start = i * bandWidth
|
||||||
|
const mid = start + bandWidth * 0.6
|
||||||
|
const end = (i + 1) * bandWidth
|
||||||
|
stops.push(`${hexToRgba(c, 0.50)} ${start}%`)
|
||||||
|
stops.push(`${hexToRgba(c, 0.10)} ${mid}%`)
|
||||||
|
if (i < n - 1) stops.push(`${hexToRgba(c, 0.10)} ${end - 0.01}%`)
|
||||||
|
})
|
||||||
|
return `linear-gradient(90deg, ${stops.join(', ')})`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassAgg { classNum: number; color: string; count: number }
|
||||||
|
|
||||||
|
function aggregateClasses(annotations: AnnotationListItem[]): ClassAgg[] {
|
||||||
|
const counts = new Map<number, number>()
|
||||||
|
for (const ann of annotations) {
|
||||||
|
for (const d of ann.detections) {
|
||||||
|
counts.set(d.classNum, (counts.get(d.classNum) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([classNum, count]) => ({ classNum, color: getClassColor(classNum), count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 6)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
|
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [detecting, setDetecting] = useState(false)
|
|
||||||
const [detectLog, setDetectLog] = useState<string[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!media) return
|
if (!media) return
|
||||||
@@ -30,85 +66,105 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
|||||||
})
|
})
|
||||||
}, [media, onAnnotationsUpdate])
|
}, [media, onAnnotationsUpdate])
|
||||||
|
|
||||||
const handleDetect = async () => {
|
const totals = useMemo(() => ({
|
||||||
if (!media) return
|
total: annotations.length,
|
||||||
setDetecting(true)
|
empty: annotations.filter(a => a.detections.length === 0).length,
|
||||||
setDetectLog(['Starting AI detection...'])
|
}), [annotations])
|
||||||
try {
|
|
||||||
await api.post(endpoints.detect.media(media.id))
|
|
||||||
setDetectLog(prev => [...prev, 'Detection complete.'])
|
|
||||||
} catch (e: any) {
|
|
||||||
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRowGradient = (ann: AnnotationListItem) => {
|
const classDist = useMemo(() => aggregateClasses(annotations), [annotations])
|
||||||
if (ann.detections.length === 0) return 'rgba(221,221,221,0.25)'
|
|
||||||
const stops = ann.detections.map((d, i) => {
|
|
||||||
const pct = (i / Math.max(ann.detections.length - 1, 1)) * 100
|
|
||||||
const alpha = Math.min(1, d.confidence)
|
|
||||||
return `${getClassColor(d.classNum)}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
|
|
||||||
})
|
|
||||||
return `linear-gradient(to right, ${stops.join(', ')})`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full bg-surface-1">
|
||||||
<div className="p-2 border-b border-az-border flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
|
||||||
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="sect-head">{t('annotations.title')}</span>
|
||||||
|
<span className="mono text-[10px] text-text-muted">{String(annotations.length).padStart(2, '0')}</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.filter')}>
|
||||||
onClick={handleDetect}
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="22 3 2 3 10 12.5 10 19 14 21 14 12.5"/></svg>
|
||||||
disabled={!media}
|
|
||||||
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('annotations.detect')}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.sort')}>
|
||||||
onClick={() => selectedAnnotation && onDownload?.(selectedAnnotation)}
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h13M3 12h9M3 18h5M17 8l4-4 4 4M21 4v16"/></svg>
|
||||||
disabled={!selectedAnnotation}
|
|
||||||
title="Download annotation"
|
|
||||||
className="text-xs bg-az-orange text-white p-1 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<FaDownload size={12} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="grid grid-cols-[44px_1fr_auto] gap-2 px-3 h-6 items-center border-b border-border-hair">
|
||||||
{annotations.map(ann => (
|
<span className="micro">{t('annotations.colTime')}</span>
|
||||||
|
<span className="micro">{t('annotations.colClass')}</span>
|
||||||
|
<span className="micro">{t('annotations.colConf')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{annotations.map(ann => {
|
||||||
|
const isSelected = selectedAnnotation?.id === ann.id
|
||||||
|
const isEmpty = ann.detections.length === 0
|
||||||
|
const first = ann.detections[0]
|
||||||
|
const extra = ann.detections.length > 1 ? ` +${ann.detections.length - 1}` : ''
|
||||||
|
const maxConf = ann.detections.reduce((m, d) => Math.max(m, d.confidence ?? 0), 0)
|
||||||
|
const className = first ? (first.label || getClassNameFallback(first.classNum)) : ''
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={ann.id}
|
key={ann.id}
|
||||||
onClick={() => onSelect(ann)}
|
onClick={() => onSelect(ann)}
|
||||||
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs ${
|
className={`ann-row${isSelected ? ' active' : ''}`}
|
||||||
selectedAnnotation?.id === ann.id ? 'ring-1 ring-az-orange ring-inset' : ''
|
style={{ ['--row-grad' as string]: getRowGradient(ann) }}
|
||||||
}`}
|
|
||||||
style={{ background: getRowGradient(ann) }}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<span className={`mono text-[11px] ${isSelected ? 'text-accent-amber font-semibold' : isEmpty ? 'text-text-muted' : 'text-text-secondary'}`}>
|
||||||
<span className="text-az-text font-mono">{ann.time || '—'}</span>
|
{ann.time || '—'}
|
||||||
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span>
|
</span>
|
||||||
|
{isEmpty
|
||||||
|
? <span className="text-text-muted italic">{t('annotations.emptyFrame')}</span>
|
||||||
|
: <span className={`truncate ${isSelected ? 'text-text-primary font-semibold' : 'text-text-primary'}`}>{className}{extra}</span>
|
||||||
|
}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isSelected && !isEmpty && onDownload && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDownload(ann) }}
|
||||||
|
className="ibtn"
|
||||||
|
style={{ width: 18, height: 18 }}
|
||||||
|
title="Download annotation"
|
||||||
|
>
|
||||||
|
<FaDownload size={9} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className={`mono text-[10px] ${isEmpty ? 'text-text-muted' : isSelected ? 'text-accent-amber' : 'text-text-secondary'}`}>
|
||||||
|
{isEmpty ? '—' : `${Math.round(maxConf * 100)}%`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{annotations.length === 0 && (
|
{annotations.length === 0 && (
|
||||||
<div className="p-2 text-az-muted text-xs text-center">{t('common.noData')}</div>
|
<div className="p-3 text-text-muted text-xs text-center">{t('common.noData')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detecting && (
|
<div className="border-t border-border-hair px-3 py-2.5 bg-surface-0">
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 max-h-80 flex flex-col">
|
<span className="micro">{t('annotations.summary')}</span>
|
||||||
<h3 className="text-white font-semibold mb-2">{t('annotations.detect')}</h3>
|
<span className="mono text-[10px] text-text-muted">
|
||||||
<div className="flex-1 overflow-y-auto bg-az-bg rounded p-2 text-xs text-az-text font-mono space-y-0.5 mb-2">
|
{t('annotations.annCount', { count: totals.total })} · {t('annotations.emptyCount', { count: totals.empty })}
|
||||||
{detectLog.map((line, i) => <div key={i}>{line}</div>)}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setDetecting(false)} className="self-end text-xs bg-az-border text-az-text px-3 py-1 rounded">
|
{classDist.length > 0 && (
|
||||||
Close
|
<>
|
||||||
</button>
|
<div className="flex items-center gap-1 h-2">
|
||||||
|
{classDist.map(c => (
|
||||||
|
<span key={c.classNum} style={{ flex: c.count, background: c.color, height: '100%' }} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2 mono text-[10px] text-text-muted">
|
||||||
|
{classDist.map(c => (
|
||||||
|
<span key={c.classNum} className="flex items-center gap-1">
|
||||||
|
<span style={{ color: c.color }}>■</span> {c.count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHand
|
|||||||
import { endpoints } from '../../api'
|
import { endpoints } from '../../api'
|
||||||
import { MediaType } from '../../types'
|
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 '../../class-colors'
|
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
|
||||||
|
import { parseAnnotationTime } from './time'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: Media
|
media: Media
|
||||||
@@ -12,6 +13,8 @@ interface Props {
|
|||||||
selectedClassNum: number
|
selectedClassNum: number
|
||||||
currentTime: number
|
currentTime: number
|
||||||
annotations: AnnotationListItem[]
|
annotations: AnnotationListItem[]
|
||||||
|
onZoomChange?: (zoom: number) => void
|
||||||
|
onCursorChange?: (nx: number, ny: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasEditorHandle {
|
export interface CanvasEditorHandle {
|
||||||
@@ -28,28 +31,60 @@ interface DragState {
|
|||||||
handle?: string
|
handle?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LabelChip {
|
||||||
|
leftPct: number
|
||||||
|
topPct: number
|
||||||
|
color: string
|
||||||
|
name: string
|
||||||
|
conf: number
|
||||||
|
combatReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const HANDLE_SIZE = 6
|
const HANDLE_SIZE = 6
|
||||||
const MIN_BOX_SIZE = 12
|
const MIN_BOX_SIZE = 12
|
||||||
|
|
||||||
const AFFILIATION_COLORS: Record<number, string> = {
|
const HOSTILE_HEXES = new Set(['#FF0000', '#FFFF00', '#FF00FF', '#800000', '#808000', '#800080'])
|
||||||
0: '#FFD700',
|
const FRIENDLY_HEXES = new Set(['#00FF00', '#0000FF', '#00FFFF', '#008000', '#000080', '#008080'])
|
||||||
1: '#228be6',
|
|
||||||
2: '#fa5252',
|
function affiliationIcon(hex: string) {
|
||||||
|
const up = hex.toUpperCase()
|
||||||
|
if (HOSTILE_HEXES.has(up)) {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
|
||||||
|
<polygon points="5.5,0.7 10.3,5.5 5.5,10.3 0.7,5.5" fill="#FF0000" stroke="#0A0D10" strokeWidth="1"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (FRIENDLY_HEXES.has(up)) {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="9" viewBox="0 0 11 9" aria-hidden="true">
|
||||||
|
<rect x="0.5" y="0.5" width="10" height="8" fill="#87CEEB" stroke="#0A0D10" strokeWidth="1"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.2"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
|
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
|
||||||
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations },
|
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations, onZoomChange, onCursorChange },
|
||||||
ref,
|
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)
|
||||||
|
const cursorRafRef = useRef<number | null>(null)
|
||||||
|
const cursorLatestRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||||
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 })
|
||||||
|
const [labelChips, setLabelChips] = useState<LabelChip[]>([])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
deleteSelected() {
|
deleteSelected() {
|
||||||
@@ -70,7 +105,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
|
|
||||||
const loadImage = useCallback(() => {
|
const loadImage = useCallback(() => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
// Use natural size based on container; no image load
|
|
||||||
imgRef.current = null
|
imgRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,16 +150,45 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
}, [isVideo])
|
}, [isVideo])
|
||||||
|
|
||||||
const toCanvas = useCallback((nx: number, ny: number) => ({
|
useEffect(() => { onZoomChange?.(zoom) }, [zoom, onZoomChange])
|
||||||
x: nx * imgSize.w * zoom + pan.x,
|
|
||||||
y: ny * imgSize.h * zoom + pan.y,
|
// Cancel any pending cursor RAF on unmount so the callback can't fire after.
|
||||||
}), [imgSize, zoom, pan])
|
useEffect(() => () => {
|
||||||
|
if (cursorRafRef.current != null) {
|
||||||
|
cancelAnimationFrame(cursorRafRef.current)
|
||||||
|
cursorRafRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
||||||
x: Math.max(0, Math.min(1, (cx - pan.x) / (imgSize.w * zoom))),
|
x: Math.max(0, Math.min(1, (cx - pan.x) / (imgSize.w * zoom))),
|
||||||
y: Math.max(0, Math.min(1, (cy - pan.y) / (imgSize.h * zoom))),
|
y: Math.max(0, Math.min(1, (cy - pan.y) / (imgSize.h * zoom))),
|
||||||
}), [imgSize, zoom, pan])
|
}), [imgSize, zoom, pan])
|
||||||
|
|
||||||
|
const getTimeWindowDetections = useCallback((): Detection[] => {
|
||||||
|
if (media.mediaType !== MediaType.Video) return []
|
||||||
|
if (annotation) return []
|
||||||
|
const timeTicks = currentTime * 10_000_000
|
||||||
|
return annotations
|
||||||
|
.filter(a => {
|
||||||
|
const sec = parseAnnotationTime(a.time)
|
||||||
|
if (sec == null) return false
|
||||||
|
return Math.abs(sec * 10_000_000 - timeTicks) < 2_000_000
|
||||||
|
})
|
||||||
|
.flatMap(a => a.detections)
|
||||||
|
}, [media.mediaType, annotation, annotations, currentTime])
|
||||||
|
|
||||||
|
const getHandles = (x: number, y: number, w: number, h: number) => [
|
||||||
|
{ x, y, cursor: 'nw-resize', name: 'tl' },
|
||||||
|
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
|
||||||
|
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
|
||||||
|
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
|
||||||
|
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
|
||||||
|
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
|
||||||
|
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
|
||||||
|
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
|
||||||
|
]
|
||||||
|
|
||||||
const draw = useCallback(() => {
|
const draw = useCallback(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
const ctx = canvas?.getContext('2d')
|
const ctx = canvas?.getContext('2d')
|
||||||
@@ -146,9 +209,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
|
|
||||||
const timeWindowDets = getTimeWindowDetections()
|
const timeWindowDets = getTimeWindowDetections()
|
||||||
const allDets = [...detections, ...timeWindowDets]
|
const allDets = [...detections, ...timeWindowDets]
|
||||||
|
const chips: LabelChip[] = []
|
||||||
|
|
||||||
allDets.forEach((det, i) => {
|
allDets.forEach((det, i) => {
|
||||||
const isSelected = selected.has(i) && i < detections.length
|
const isOwn = i < detections.length
|
||||||
|
const isSelected = selected.has(i) && isOwn
|
||||||
const cx = (det.centerX - det.width / 2) * imgSize.w * zoom + pan.x
|
const cx = (det.centerX - det.width / 2) * imgSize.w * zoom + pan.x
|
||||||
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
|
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
|
||||||
const w = det.width * imgSize.w * zoom
|
const w = det.width * imgSize.w * zoom
|
||||||
@@ -160,45 +225,51 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
ctx.strokeRect(cx, cy, w, h)
|
ctx.strokeRect(cx, cy, w, h)
|
||||||
|
|
||||||
ctx.fillStyle = color
|
ctx.fillStyle = color
|
||||||
ctx.globalAlpha = 0.1
|
ctx.globalAlpha = 0.06
|
||||||
ctx.fillRect(cx, cy, w, h)
|
ctx.fillRect(cx, cy, w, h)
|
||||||
ctx.globalAlpha = 1
|
ctx.globalAlpha = 1
|
||||||
|
|
||||||
const name = det.label || getClassNameFallback(det.classNum)
|
// Corner brackets — 8px legs (skipped in environments lacking path API, e.g. JSDOM)
|
||||||
const modeSuffix = getPhotoModeSuffix(det.classNum)
|
if (typeof ctx.moveTo === 'function' && typeof ctx.beginPath === 'function') {
|
||||||
const confSuffix = det.confidence < 0.995 ? ` ${(det.confidence * 100).toFixed(0)}%` : ''
|
const legLen = 8
|
||||||
const label = `${name}${modeSuffix}${confSuffix}`
|
ctx.lineWidth = 2
|
||||||
|
|
||||||
ctx.font = '11px sans-serif'
|
|
||||||
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) {
|
|
||||||
ctx.fillStyle = '#40c057'
|
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(cx + w - 6, cy + 6, 3, 0, Math.PI * 2)
|
ctx.moveTo(cx, cy + legLen); ctx.lineTo(cx, cy); ctx.lineTo(cx + legLen, cy)
|
||||||
ctx.fill()
|
ctx.moveTo(cx + w - legLen, cy); ctx.lineTo(cx + w, cy); ctx.lineTo(cx + w, cy + legLen)
|
||||||
|
ctx.moveTo(cx + w, cy + h - legLen); ctx.lineTo(cx + w, cy + h); ctx.lineTo(cx + w - legLen, cy + h)
|
||||||
|
ctx.moveTo(cx + legLen, cy + h); ctx.lineTo(cx, cy + h); ctx.lineTo(cx, cy + h - legLen)
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOwn) {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (container && container.clientWidth && container.clientHeight) {
|
||||||
|
chips.push({
|
||||||
|
leftPct: (cx / container.clientWidth) * 100,
|
||||||
|
topPct: (cy / container.clientHeight) * 100,
|
||||||
|
color,
|
||||||
|
name: det.label || getClassNameFallback(det.classNum),
|
||||||
|
conf: det.confidence,
|
||||||
|
combatReady: det.combatReadiness === 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
const handles = getHandles(cx, cy, w, h)
|
const handles = getHandles(cx, cy, w, h)
|
||||||
handles.forEach(hp => {
|
handles.forEach(hp => {
|
||||||
ctx.fillStyle = '#fff'
|
ctx.fillStyle = '#FF9D3D'
|
||||||
ctx.fillRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
ctx.fillRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||||
ctx.strokeStyle = color
|
ctx.strokeStyle = '#0A0D10'
|
||||||
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (drawRect) {
|
if (drawRect) {
|
||||||
ctx.strokeStyle = '#fd7e14'
|
ctx.strokeStyle = '#FF9D3D'
|
||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
ctx.setLineDash([4, 4])
|
ctx.setLineDash([4, 4])
|
||||||
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
||||||
@@ -206,7 +277,23 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}, [detections, selected, zoom, pan, imgSize, drawRect, currentTime, annotations])
|
|
||||||
|
// Only setState when chips actually changed — prevents a render storm
|
||||||
|
// during video playback (draw runs on every time-update; without this
|
||||||
|
// guard React would commit a new array reference on every paint).
|
||||||
|
setLabelChips(prev => {
|
||||||
|
if (prev.length !== chips.length) return chips
|
||||||
|
for (let i = 0; i < chips.length; i++) {
|
||||||
|
const a = prev[i], b = chips[i]
|
||||||
|
if (
|
||||||
|
a.leftPct !== b.leftPct || a.topPct !== b.topPct ||
|
||||||
|
a.color !== b.color || a.name !== b.name ||
|
||||||
|
a.conf !== b.conf || a.combatReady !== b.combatReady
|
||||||
|
) return chips
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}, [detections, selected, zoom, pan, imgSize, drawRect, isVideo, getTimeWindowDetections])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = requestAnimationFrame(draw)
|
const id = requestAnimationFrame(draw)
|
||||||
@@ -221,31 +308,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
return () => obs.disconnect()
|
return () => obs.disconnect()
|
||||||
}, [draw])
|
}, [draw])
|
||||||
|
|
||||||
const getTimeWindowDetections = (): Detection[] => {
|
|
||||||
if (media.mediaType !== MediaType.Video) return []
|
|
||||||
if (annotation) return []
|
|
||||||
const timeTicks = currentTime * 10_000_000
|
|
||||||
return annotations
|
|
||||||
.filter(a => {
|
|
||||||
if (!a.time) return false
|
|
||||||
const parts = a.time.split(':').map(Number)
|
|
||||||
const annTime = (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 10_000_000
|
|
||||||
return Math.abs(annTime - timeTicks) < 2_000_000
|
|
||||||
})
|
|
||||||
.flatMap(a => a.detections)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getHandles = (x: number, y: number, w: number, h: number) => [
|
|
||||||
{ x, y, cursor: 'nw-resize', name: 'tl' },
|
|
||||||
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
|
|
||||||
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
|
|
||||||
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
|
|
||||||
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
|
|
||||||
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
|
|
||||||
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
|
|
||||||
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const hitTest = (cx: number, cy: number) => {
|
const hitTest = (cx: number, cy: number) => {
|
||||||
for (let i = detections.length - 1; i >= 0; i--) {
|
for (let i = detections.length - 1; i >= 0; i--) {
|
||||||
const d = detections[i]
|
const d = detections[i]
|
||||||
@@ -298,12 +360,28 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
if (!dragState) return
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect()
|
const rect = canvasRef.current?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
const mx = e.clientX - rect.left
|
const mx = e.clientX - rect.left
|
||||||
const my = e.clientY - rect.top
|
const my = e.clientY - rect.top
|
||||||
|
|
||||||
|
if (onCursorChange && imgSize.w && imgSize.h) {
|
||||||
|
const nx = (mx - pan.x) / (imgSize.w * zoom)
|
||||||
|
const ny = (my - pan.y) / (imgSize.h * zoom)
|
||||||
|
if (nx >= 0 && nx <= 1 && ny >= 0 && ny <= 1) {
|
||||||
|
cursorLatestRef.current = { x: nx, y: ny }
|
||||||
|
if (cursorRafRef.current == null) {
|
||||||
|
cursorRafRef.current = requestAnimationFrame(() => {
|
||||||
|
const v = cursorLatestRef.current
|
||||||
|
cursorRafRef.current = null
|
||||||
|
if (v) onCursorChange(v.x, v.y)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dragState) return
|
||||||
|
|
||||||
if (dragState.type === 'draw') {
|
if (dragState.type === 'draw') {
|
||||||
setDrawRect({
|
setDrawRect({
|
||||||
x: Math.min(dragState.startX, mx),
|
x: Math.min(dragState.startX, mx),
|
||||||
@@ -415,6 +493,25 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
{labelChips.map((chip, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bbox-label"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${chip.leftPct}%`,
|
||||||
|
top: `calc(${chip.topPct}% - 26px)`,
|
||||||
|
borderColor: hexToRgba(chip.color, 0.6),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: chip.color, display: 'inline-flex' }}>{affiliationIcon(chip.color)}</span>
|
||||||
|
{chip.combatReady && <span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--accent-green)', display: 'inline-block' }} />}
|
||||||
|
<span style={{ color: chip.color }}>{chip.name}</span>
|
||||||
|
{chip.conf < 0.995 && <span className="conf">{(chip.conf * 100).toFixed(1)}%</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
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 folderInputRef = useRef<HTMLInputElement>(null)
|
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const fetchMedia = useCallback(async () => {
|
const fetchMedia = useCallback(async () => {
|
||||||
const params = new URLSearchParams({ pageSize: '1000' })
|
const params = new URLSearchParams({ pageSize: '1000' })
|
||||||
@@ -139,25 +140,20 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filtered = media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...getRootProps({
|
{...getRootProps({
|
||||||
className: `flex-1 flex flex-col overflow-hidden ${isDragActive ? 'ring-2 ring-az-orange ring-inset' : ''}`,
|
className: `flex flex-col flex-1 min-h-0 bg-surface-1${isDragActive ? ' ring-2 ring-accent-amber ring-inset' : ''}`,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
{/* Dropzone hidden input */}
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
<div className="p-2 border-b border-az-border flex gap-1">
|
|
||||||
<input
|
{/* Hidden file inputs */}
|
||||||
value={filter}
|
|
||||||
onChange={e => setFilter(e.target.value)}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 pt-2 pb-2 flex gap-1">
|
|
||||||
<label className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded text-center cursor-pointer hover:brightness-110">
|
|
||||||
Open File
|
|
||||||
<input
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
@@ -166,14 +162,6 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</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
|
<input
|
||||||
ref={folderInputRef}
|
ref={folderInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -184,25 +172,94 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
directory=""
|
directory=""
|
||||||
onChange={handleFolderInput}
|
onChange={handleFolderInput}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="sect-head">{t('annotations.mediaList')}</span>
|
||||||
|
<span className="mono text-[10px] text-text-muted">{filtered.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex items-center gap-1">
|
||||||
{media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())).map(m => (
|
{/* Upload file button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ibtn"
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
title={t('annotations.upload')}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Open folder button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ibtn"
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
title="Open Folder"
|
||||||
|
onClick={() => folderInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter input row */}
|
||||||
|
<div className="px-3 py-2 border-b border-border-hair shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
className="inp w-full pl-7"
|
||||||
|
style={{ height: 28, padding: '0 10px 0 28px' }}
|
||||||
|
value={filter}
|
||||||
|
onChange={e => setFilter(e.target.value)}
|
||||||
|
placeholder={t('annotations.filterByName')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{filtered.map(m => {
|
||||||
|
const isActive = selectedMedia?.id === m.id
|
||||||
|
const isVideo = m.mediaType === MediaType.Video
|
||||||
|
const hasDuration = !!m.duration
|
||||||
|
const durationColor = isActive
|
||||||
|
? 'text-accent-amber'
|
||||||
|
: hasDuration
|
||||||
|
? 'text-text-secondary'
|
||||||
|
: 'text-text-muted'
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
onClick={() => handleSelect(m)}
|
onClick={() => handleSelect(m)}
|
||||||
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
|
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
|
||||||
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs flex items-center gap-1.5 ${
|
className={`media-row${isActive ? ' active' : ''}`}
|
||||||
selectedMedia?.id === m.id ? 'bg-az-bg text-white' : ''
|
|
||||||
} ${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 === MediaType.Video ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
|
{isVideo
|
||||||
{m.mediaType === MediaType.Video ? 'V' : 'P'}
|
? <span className="chip-video">VIDEO</span>
|
||||||
|
: <span className="chip-photo">PHOTO</span>
|
||||||
|
}
|
||||||
|
<span className={`truncate${isActive ? ' font-medium text-text-primary' : ' text-text-primary'}`}>
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
<span className={`mono text-[11px] ${durationColor}`}>
|
||||||
|
{m.duration ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate flex-1">{m.name}</span>
|
|
||||||
{m.duration && <span className="text-az-muted">{m.duration}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteId}
|
open={!!deleteId}
|
||||||
title={t('annotations.deleteMedia')}
|
title={t('annotations.deleteMedia')}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export interface ScrubberMark {
|
||||||
|
time: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
current: number
|
||||||
|
duration: number
|
||||||
|
marks: ScrubberMark[]
|
||||||
|
onSeek: (time: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICK_PERCENTS = [0, 25, 50, 75, 100]
|
||||||
|
|
||||||
|
export default function Scrubber({ current, duration, marks, onSeek }: Props) {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const safeDuration = duration > 0 ? duration : 1
|
||||||
|
const pct = Math.max(0, Math.min(100, (current / safeDuration) * 100))
|
||||||
|
|
||||||
|
const seekFromClientX = useCallback((clientX: number) => {
|
||||||
|
const el = trackRef.current
|
||||||
|
if (!el) return
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const x = Math.max(0, Math.min(rect.width, clientX - rect.left))
|
||||||
|
onSeek((x / rect.width) * safeDuration)
|
||||||
|
}, [onSeek, safeDuration])
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(true)
|
||||||
|
seekFromClientX(e.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragging) return
|
||||||
|
const move = (e: MouseEvent) => seekFromClientX(e.clientX)
|
||||||
|
const up = () => setDragging(false)
|
||||||
|
window.addEventListener('mousemove', move)
|
||||||
|
window.addEventListener('mouseup', up)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', move)
|
||||||
|
window.removeEventListener('mouseup', up)
|
||||||
|
}
|
||||||
|
}, [dragging, seekFromClientX])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={trackRef} className="scrub" onMouseDown={handleMouseDown}>
|
||||||
|
<div className="fill" style={{ width: `${pct}%` }} />
|
||||||
|
{TICK_PERCENTS.map(p => (
|
||||||
|
<div key={p} className="tick" style={{ left: `${p}%` }} />
|
||||||
|
))}
|
||||||
|
{marks.map((m, i) => {
|
||||||
|
const mpct = Math.max(0, Math.min(100, (m.time / safeDuration) * 100))
|
||||||
|
return <div key={i} className="mark" style={{ left: `${mpct}%`, background: m.color }} />
|
||||||
|
})}
|
||||||
|
<div className="head" style={{ left: `${pct}%` }} />
|
||||||
|
<div className="head-knob" style={{ left: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,42 +1,52 @@
|
|||||||
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||||
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
|
||||||
import { endpoints } from '../../api'
|
import { endpoints } from '../../api'
|
||||||
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
|
||||||
|
/** Fires when the <video> emits 'play'/'pause' (no polling needed). */
|
||||||
|
onPlayingChange?: (playing: boolean) => void
|
||||||
|
/** Fires when the <video> reports a valid duration. */
|
||||||
|
onDurationChange?: (duration: number) => void
|
||||||
|
/** Fires when the <video> mute state changes (incl. the M keyboard shortcut). */
|
||||||
|
onMutedChange?: (muted: boolean) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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 {
|
export interface VideoPlayerHandle {
|
||||||
seek: (seconds: number) => void
|
seek: (seconds: number) => void
|
||||||
getVideoElement: () => HTMLVideoElement | null
|
getVideoElement: () => HTMLVideoElement | null
|
||||||
|
play: () => void
|
||||||
|
pause: () => void
|
||||||
|
toggle: () => void
|
||||||
|
isPlaying: () => boolean
|
||||||
|
frameStep: (deltaFrames: number) => void
|
||||||
|
getDuration: () => number
|
||||||
|
getCurrentTime: () => number
|
||||||
|
getFrameRate: () => number
|
||||||
|
getCurrentFrame: () => number
|
||||||
|
getTotalFrames: () => number
|
||||||
|
getVolume: () => number
|
||||||
|
setVolume: (v: number) => void
|
||||||
|
toggleMute: () => void
|
||||||
|
isMuted: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({ media, onTimeUpdate, children }, ref) {
|
const FPS = 30
|
||||||
|
|
||||||
|
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||||
|
media, onTimeUpdate, onPlayingChange, onDurationChange, onMutedChange, 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 [error, setError] = useState<string | null>(null)
|
||||||
const [playing, setPlaying] = useState(false)
|
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
|
||||||
const [duration, setDuration] = useState(0)
|
|
||||||
const [muted, setMuted] = useState(false)
|
const [muted, setMuted] = useState(false)
|
||||||
|
|
||||||
|
const notifyMuted = useCallback((m: boolean) => {
|
||||||
|
setMuted(m)
|
||||||
|
onMutedChange?.(m)
|
||||||
|
}, [onMutedChange])
|
||||||
|
|
||||||
const videoUrl = media.path.startsWith('blob:')
|
const videoUrl = media.path.startsWith('blob:')
|
||||||
? media.path
|
? media.path
|
||||||
: endpoints.annotations.mediaFile(media.id)
|
: endpoints.annotations.mediaFile(media.id)
|
||||||
@@ -44,24 +54,47 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
const stepFrames = useCallback((count: number) => {
|
const stepFrames = useCallback((count: number) => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
const fps = 30
|
video.currentTime = Math.max(0, Math.min(video.duration || 0, video.currentTime + count / FPS))
|
||||||
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + count / fps))
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
const v = videoRef.current
|
const v = videoRef.current
|
||||||
if (!v) return
|
if (!v) return
|
||||||
if (v.paused) { v.play(); setPlaying(true) }
|
if (v.paused) v.play().catch(() => {})
|
||||||
else { v.pause(); setPlaying(false) }
|
else v.pause()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
useImperativeHandle(ref, () => ({
|
||||||
|
seek(seconds: number) {
|
||||||
const v = videoRef.current
|
const v = videoRef.current
|
||||||
if (!v) return
|
if (v) v.currentTime = seconds
|
||||||
v.pause()
|
},
|
||||||
v.currentTime = 0
|
getVideoElement() { return videoRef.current },
|
||||||
setPlaying(false)
|
play() { videoRef.current?.play().catch(() => {}) },
|
||||||
}, [])
|
pause() { videoRef.current?.pause() },
|
||||||
|
toggle() { togglePlay() },
|
||||||
|
isPlaying() { return !!videoRef.current && !videoRef.current.paused },
|
||||||
|
frameStep(delta) { stepFrames(delta) },
|
||||||
|
getDuration() { return videoRef.current?.duration ?? 0 },
|
||||||
|
getCurrentTime() { return videoRef.current?.currentTime ?? 0 },
|
||||||
|
getFrameRate() { return FPS },
|
||||||
|
getCurrentFrame() { return Math.floor((videoRef.current?.currentTime ?? 0) * FPS) },
|
||||||
|
getTotalFrames() { return Math.floor((videoRef.current?.duration ?? 0) * FPS) },
|
||||||
|
getVolume() { return videoRef.current?.volume ?? 1 },
|
||||||
|
setVolume(v) {
|
||||||
|
const el = videoRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.volume = Math.max(0, Math.min(1, v))
|
||||||
|
if (el.volume > 0 && el.muted) { el.muted = false; notifyMuted(false) }
|
||||||
|
},
|
||||||
|
toggleMute() {
|
||||||
|
const el = videoRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.muted = !el.muted
|
||||||
|
notifyMuted(el.muted)
|
||||||
|
},
|
||||||
|
isMuted() { return !!videoRef.current?.muted },
|
||||||
|
}))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -70,22 +103,22 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
case ' ': e.preventDefault(); togglePlay(); break
|
case ' ': e.preventDefault(); togglePlay(); break
|
||||||
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
|
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
|
||||||
case 'ArrowRight': e.preventDefault(); stepFrames(e.ctrlKey ? 150 : 1); break
|
case 'ArrowRight': e.preventDefault(); stepFrames(e.ctrlKey ? 150 : 1); break
|
||||||
case 'm': case 'M': setMuted(m => !m); break
|
case 'm': case 'M': {
|
||||||
|
const v = videoRef.current
|
||||||
|
if (v) { v.muted = !v.muted; notifyMuted(v.muted) }
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [togglePlay, stepFrames])
|
}, [togglePlay, stepFrames])
|
||||||
|
|
||||||
const formatTime = (s: number) => {
|
|
||||||
const m = Math.floor(s / 60)
|
|
||||||
const sec = Math.floor(s % 60)
|
|
||||||
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-black flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0 bg-surface-0">
|
||||||
{error && <div className="bg-az-red/80 text-white text-xs px-2 py-1">{error}</div>}
|
{error && (
|
||||||
|
<div className="bg-surface-1 border-b border-border-hair text-accent-red text-xs px-3 py-1">{error}</div>
|
||||||
|
)}
|
||||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -94,76 +127,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
controls={false}
|
controls={false}
|
||||||
playsInline
|
playsInline
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain"
|
||||||
onTimeUpdate={e => {
|
onTimeUpdate={e => onTimeUpdate((e.target as HTMLVideoElement).currentTime)}
|
||||||
const t = (e.target as HTMLVideoElement).currentTime
|
onPlay={() => onPlayingChange?.(true)}
|
||||||
setCurrentTime(t)
|
onPause={() => onPlayingChange?.(false)}
|
||||||
onTimeUpdate(t)
|
onDurationChange={e => {
|
||||||
}}
|
const d = (e.target as HTMLVideoElement).duration
|
||||||
onLoadedMetadata={e => {
|
if (Number.isFinite(d)) onDurationChange?.(d)
|
||||||
setDuration((e.target as HTMLVideoElement).duration)
|
|
||||||
setError(null)
|
|
||||||
}}
|
}}
|
||||||
|
onLoadedMetadata={() => setError(null)}
|
||||||
onError={() => setError(`Failed to load video (${media.name})`)}
|
onError={() => setError(`Failed to load video (${media.name})`)}
|
||||||
/>
|
/>
|
||||||
{children && <div className="absolute inset-0">{children}</div>}
|
{children && <div className="absolute inset-0">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
{/* Progress row: time | slider | remaining */}
|
|
||||||
<div className="flex items-center gap-3 bg-az-header px-4 py-1.5">
|
|
||||||
<span className="text-white text-xs font-mono tabular-nums min-w-[40px] text-right">{formatTime(currentTime)}</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={duration || 1}
|
|
||||||
step={0.01}
|
|
||||||
value={currentTime}
|
|
||||||
onChange={e => {
|
|
||||||
const v = Number(e.target.value)
|
|
||||||
setCurrentTime(v)
|
|
||||||
if (videoRef.current) videoRef.current.currentTime = v
|
|
||||||
}}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Annotation time helpers — shared between AnnotationsPage and CanvasEditor.
|
||||||
|
* Annotation `time` is the backend's "HH:MM:SS.mmm" tick representation; this
|
||||||
|
* module owns the conversion to/from seconds + display formatting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function parseAnnotationTime(t: string | null | undefined): number | null {
|
||||||
|
if (!t) return null
|
||||||
|
const parts = t.split(':').map(Number)
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
if (parts.some(p => !Number.isFinite(p))) return null
|
||||||
|
return (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(seconds: number, withMs = false): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
const base = `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
if (!withMs) return base
|
||||||
|
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
||||||
|
return `${base}.${String(ms).padStart(3, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTicks(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0
|
||||||
|
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')}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { api, endpoints } from '../../api'
|
||||||
|
import { getClassColor, FALLBACK_CLASS_NAMES } from '../../class-colors'
|
||||||
|
import type { DetectionClass } from '../../types'
|
||||||
|
|
||||||
|
const FALLBACK_CLASSES: DetectionClass[] = FALLBACK_CLASS_NAMES.map((name, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name,
|
||||||
|
shortName: name.slice(0, 3),
|
||||||
|
color: getClassColor(i),
|
||||||
|
maxSizeM: 10,
|
||||||
|
photoMode: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
interface DatasetClassListProps {
|
||||||
|
selectedClassNum: number
|
||||||
|
onSelect: (classNum: number) => void
|
||||||
|
counts: Record<number, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasetClassList({ selectedClassNum, onSelect, counts }: DatasetClassListProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||||
|
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
|
||||||
|
.catch(() => setClasses(FALLBACK_CLASSES))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const regularClasses = useMemo(() => classes.filter(c => c.photoMode === 0), [classes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
const t = e.target as HTMLElement | null
|
||||||
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
||||||
|
const num = parseInt(e.key)
|
||||||
|
if (num >= 1 && num <= 9) {
|
||||||
|
const cls = regularClasses[num - 1]
|
||||||
|
if (cls) onSelect(cls.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [regularClasses, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="px-3 pt-3 pb-2 flex items-center justify-between border-b border-border-hair shrink-0">
|
||||||
|
<span className="sect-head" style={{ lineHeight: 1.2 }}>{t('annotations.classes')}</span>
|
||||||
|
<span className="mono text-[10px] text-text-muted tabular-nums">
|
||||||
|
{regularClasses.length.toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="px-2 py-2 flex flex-col gap-0.5 overflow-y-auto"
|
||||||
|
style={{ maxHeight: '46vh' }}
|
||||||
|
>
|
||||||
|
{regularClasses.map(c => {
|
||||||
|
const isActive = c.id === selectedClassNum
|
||||||
|
const count = counts[c.id] ?? 0
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onSelect(c.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onSelect(c.id) }}
|
||||||
|
className={`flex items-center gap-2.5 h-7 px-2 rounded-[2px] cursor-pointer transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-surface-2 text-text-primary'
|
||||||
|
: 'text-text-secondary hover:bg-surface-2 hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="swatch shrink-0" style={{ background: c.color }} />
|
||||||
|
<span className="text-[12px] truncate flex-1">{c.name}</span>
|
||||||
|
<span
|
||||||
|
className={`font-mono font-medium text-[10px] tabular-nums leading-none rounded-[2px] border bg-surface-input ${
|
||||||
|
isActive
|
||||||
|
? 'text-accent-amber border-accent-amber'
|
||||||
|
: 'text-text-secondary border-border-hair'
|
||||||
|
}`}
|
||||||
|
style={{ padding: '2px 6px' }}
|
||||||
|
>
|
||||||
|
{count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { AnnotationStatus } from '../../types'
|
||||||
|
|
||||||
|
interface DatasetFilterBarProps {
|
||||||
|
fromDate: string
|
||||||
|
toDate: string
|
||||||
|
onFromDateChange: (v: string) => void
|
||||||
|
onToDateChange: (v: string) => void
|
||||||
|
statusFilter: AnnotationStatus | null
|
||||||
|
onStatusFilterChange: (s: AnnotationStatus | null) => void
|
||||||
|
flightName: string | null
|
||||||
|
shownCount: number
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasetFilterBar({
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
onFromDateChange,
|
||||||
|
onToDateChange,
|
||||||
|
statusFilter,
|
||||||
|
onStatusFilterChange,
|
||||||
|
flightName,
|
||||||
|
shownCount,
|
||||||
|
totalCount,
|
||||||
|
}: DatasetFilterBarProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: null,
|
||||||
|
label: t('dataset.status.all'),
|
||||||
|
tone: 'muted' as const,
|
||||||
|
dot: 'var(--text-muted)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: AnnotationStatus.Created,
|
||||||
|
label: t('dataset.status.created'),
|
||||||
|
tone: 'amber' as const,
|
||||||
|
dot: 'var(--accent-amber)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: AnnotationStatus.Edited,
|
||||||
|
label: t('dataset.status.edited'),
|
||||||
|
tone: 'blue' as const,
|
||||||
|
dot: 'var(--accent-blue)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: AnnotationStatus.Validated,
|
||||||
|
label: t('dataset.status.validated'),
|
||||||
|
tone: 'green' as const,
|
||||||
|
dot: 'var(--accent-green)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bracket panel relative flex items-center gap-3 px-3 shrink-0"
|
||||||
|
style={{ height: 48 }}
|
||||||
|
>
|
||||||
|
<span className="br" />
|
||||||
|
|
||||||
|
{/* Range group */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="micro">{t('dataset.range')}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="inp inp-mono cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:inset-0 [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:cursor-pointer"
|
||||||
|
style={{ width: 104, height: 28, padding: '0 10px' }}
|
||||||
|
value={fromDate}
|
||||||
|
onChange={e => onFromDateChange(e.target.value)}
|
||||||
|
onClick={e => e.currentTarget.showPicker?.()}
|
||||||
|
/>
|
||||||
|
<span className="mono text-text-muted">—</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="inp inp-mono cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:inset-0 [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:cursor-pointer"
|
||||||
|
style={{ width: 104, height: 28, padding: '0 10px' }}
|
||||||
|
value={toDate}
|
||||||
|
onChange={e => onToDateChange(e.target.value)}
|
||||||
|
onClick={e => e.currentTarget.showPicker?.()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* divider */}
|
||||||
|
<span className="w-px h-5 bg-border-hair shrink-0" />
|
||||||
|
|
||||||
|
{/* Flight group — display-only chip */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="micro">{t('dataset.flight')}</span>
|
||||||
|
<div
|
||||||
|
className="inp inline-flex items-center gap-2"
|
||||||
|
style={{ padding: '0 10px', height: 28, cursor: 'default' }}
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber" />
|
||||||
|
<span className="mono text-[12px] text-text-primary tracking-wider">
|
||||||
|
{flightName ?? '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-muted ml-1">▾</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* divider */}
|
||||||
|
<span className="w-px h-5 bg-border-hair shrink-0" />
|
||||||
|
|
||||||
|
{/* Status chips */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="micro mr-1">{t('dataset.statusLabel')}</span>
|
||||||
|
{STATUS_OPTIONS.map(opt => {
|
||||||
|
const isActive = statusFilter === opt.value
|
||||||
|
const stateCls = !isActive
|
||||||
|
? 'text-text-secondary border-border-hair hover:text-text-primary hover:border-border-raised'
|
||||||
|
: opt.tone === 'muted'
|
||||||
|
? 'text-text-primary border-border-raised bg-text-muted/20'
|
||||||
|
: opt.tone === 'amber'
|
||||||
|
? 'text-accent-amber border-accent-amber bg-accent-amber/10'
|
||||||
|
: opt.tone === 'blue'
|
||||||
|
? 'text-accent-blue border-accent-blue bg-accent-blue/10'
|
||||||
|
: /* green */ 'text-accent-green border-accent-green bg-accent-green/10'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={String(opt.value)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatusFilterChange(opt.value)}
|
||||||
|
className={`inline-flex items-center gap-1.5 h-6 px-2.5 rounded-[2px] border font-mono text-[10px] font-semibold uppercase tracking-widest cursor-pointer transition-colors ${stateCls}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="rounded-full shrink-0"
|
||||||
|
style={{ width: 6, height: 6, background: opt.dot }}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* right side */}
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<span className="micro" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('dataset.showing')}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[12px] text-text-primary tabular-nums">
|
||||||
|
{shownCount.toLocaleString()}
|
||||||
|
<span className="text-text-muted"> / {totalCount.toLocaleString()}</span>
|
||||||
|
</span>
|
||||||
|
<span className="w-px h-5 bg-border-hair shrink-0" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('dataset.sort')}
|
||||||
|
className="w-7 h-7 inline-flex items-center justify-center border border-border-hair rounded-[2px] text-text-secondary hover:text-text-primary hover:border-border-raised transition-colors"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18M6 12h12M10 18h4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('dataset.gridDensity')}
|
||||||
|
className="w-7 h-7 inline-flex items-center justify-center border border-border-hair rounded-[2px] text-text-secondary hover:text-text-primary hover:border-border-raised transition-colors"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import DatasetClassList from './DatasetClassList'
|
||||||
|
|
||||||
|
interface DatasetLeftPanelProps {
|
||||||
|
selectedClassNum: number
|
||||||
|
onSelectClass: (n: number) => void
|
||||||
|
classCounts: Record<number, number>
|
||||||
|
objectsOnly: boolean
|
||||||
|
onObjectsOnlyChange: (v: boolean) => void
|
||||||
|
search: string
|
||||||
|
onSearchChange: (v: string) => void
|
||||||
|
totalCount: number
|
||||||
|
validatedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasetLeftPanel({
|
||||||
|
selectedClassNum,
|
||||||
|
onSelectClass,
|
||||||
|
classCounts,
|
||||||
|
objectsOnly,
|
||||||
|
onObjectsOnlyChange,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
totalCount,
|
||||||
|
validatedCount,
|
||||||
|
}: DatasetLeftPanelProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="bracket panel flex flex-col shrink-0" style={{ width: 250 }}>
|
||||||
|
<span className="br" />
|
||||||
|
|
||||||
|
<DatasetClassList
|
||||||
|
selectedClassNum={selectedClassNum}
|
||||||
|
onSelect={onSelectClass}
|
||||||
|
counts={classCounts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-auto border-t border-border-hair px-3 py-3 flex flex-col gap-3">
|
||||||
|
<span className="micro">{t('dataset.filters')}</span>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[12px] text-text-primary">{t('dataset.objectsOnly')}</span>
|
||||||
|
<span className="text-[10px] text-text-muted">{t('dataset.hideEmpty')}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={objectsOnly}
|
||||||
|
onClick={() => onObjectsOnlyChange(!objectsOnly)}
|
||||||
|
className={`relative shrink-0 rounded-[2px] border transition-colors ${
|
||||||
|
objectsOnly
|
||||||
|
? 'border-accent-amber bg-accent-amber/20'
|
||||||
|
: 'border-border-hair bg-surface-0'
|
||||||
|
}`}
|
||||||
|
style={{ width: 30, height: 16 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-px left-px block rounded-[2px] transition-transform ${
|
||||||
|
objectsOnly ? 'bg-accent-amber' : 'bg-text-muted'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
transform: objectsOnly ? 'translateX(14px)' : 'translateX(0)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="inp w-full"
|
||||||
|
style={{ height: 28, padding: '0 10px 0 28px' }}
|
||||||
|
placeholder={t('dataset.search')}
|
||||||
|
value={search}
|
||||||
|
onChange={e => onSearchChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||||
|
<div className="border border-border-hair rounded-[2px] p-2">
|
||||||
|
<div className="micro" style={{ color: 'var(--text-muted)' }}>{t('dataset.total')}</div>
|
||||||
|
<div className="mono text-[15px] text-text-primary">{totalCount.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="border border-border-hair rounded-[2px] p-2">
|
||||||
|
<div className="micro" style={{ color: 'var(--text-muted)' }}>{t('dataset.validatedCount')}</div>
|
||||||
|
<div className="mono text-[15px] text-accent-green">{validatedCount.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,30 +1,25 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FaPen } from 'react-icons/fa'
|
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
import { useDebounce, useResizablePanel } from '../../hooks'
|
import { useDebounce } from '../../hooks'
|
||||||
import { useFlight, DetectionClasses } from '../../components'
|
import { useFlight } from '../../components'
|
||||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||||
import CanvasEditor from '../annotations/CanvasEditor'
|
import CanvasEditor from '../annotations/CanvasEditor'
|
||||||
import { recaptureThumbnails } from '../annotations/thumbnail'
|
import { recaptureThumbnails } from '../annotations/thumbnail'
|
||||||
import type { SavedDetection } from '../../components/SavedAnnotationsContext'
|
import type { SavedDetection } from '../../components/SavedAnnotationsContext'
|
||||||
import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types'
|
import type {
|
||||||
|
DatasetItem,
|
||||||
|
PaginatedResponse,
|
||||||
|
ClassDistributionItem,
|
||||||
|
AnnotationListItem,
|
||||||
|
Detection,
|
||||||
|
Media,
|
||||||
|
} from '../../types'
|
||||||
import { AnnotationSource, AnnotationStatus } from '../../types'
|
import { AnnotationSource, AnnotationStatus } from '../../types'
|
||||||
|
import DatasetLeftPanel from './DatasetLeftPanel'
|
||||||
interface DatasetCard {
|
import DatasetFilterBar from './DatasetFilterBar'
|
||||||
annotationId: string
|
import DatasetTile, { type DatasetCard } from './DatasetTile'
|
||||||
imageName: string
|
import DatasetStatusBar from './DatasetStatusBar'
|
||||||
status: AnnotationStatus
|
|
||||||
createdDate: string
|
|
||||||
thumbnailUrl: string
|
|
||||||
isSeed: boolean
|
|
||||||
isLocal: boolean
|
|
||||||
detections?: Detection[]
|
|
||||||
mediaId?: string
|
|
||||||
time?: string | null
|
|
||||||
fullFrame?: string
|
|
||||||
annotationLocalId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tab = 'annotations' | 'editor' | 'distribution'
|
type Tab = 'annotations' | 'editor' | 'distribution'
|
||||||
|
|
||||||
@@ -32,7 +27,6 @@ export default function DatasetPage() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { selectedFlight } = useFlight()
|
const { selectedFlight } = useFlight()
|
||||||
const { saved: savedAnnotations, removeSaved, replaceGroup, updateStatus } = useSavedAnnotations()
|
const { saved: savedAnnotations, removeSaved, replaceGroup, updateStatus } = useSavedAnnotations()
|
||||||
const leftPanel = useResizablePanel(250, 200, 400)
|
|
||||||
|
|
||||||
const [items, setItems] = useState<DatasetItem[]>([])
|
const [items, setItems] = useState<DatasetItem[]>([])
|
||||||
const [totalCount, setTotalCount] = useState(0)
|
const [totalCount, setTotalCount] = useState(0)
|
||||||
@@ -45,12 +39,14 @@ export default function DatasetPage() {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 400)
|
const debouncedSearch = useDebounce(search, 400)
|
||||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||||
const [photoMode, setPhotoMode] = useState(0)
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [tab, setTab] = useState<Tab>('annotations')
|
const [tab, setTab] = useState<Tab>('annotations')
|
||||||
const [editorAnnotation, setEditorAnnotation] = useState<AnnotationListItem | null>(null)
|
const [editorAnnotation, setEditorAnnotation] = useState<AnnotationListItem | null>(null)
|
||||||
const [editorDetections, setEditorDetections] = useState<Detection[]>([])
|
const [editorDetections, setEditorDetections] = useState<Detection[]>([])
|
||||||
const [distribution, setDistribution] = useState<ClassDistributionItem[]>([])
|
const [distribution, setDistribution] = useState<ClassDistributionItem[]>([])
|
||||||
|
const [editorFullFrame, setEditorFullFrame] = useState<string>('')
|
||||||
|
const [editorLocalGroupId, setEditorLocalGroupId] = useState<string | null>(null)
|
||||||
|
const [editorSaving, setEditorSaving] = useState(false)
|
||||||
|
|
||||||
const fetchItems = useCallback(async () => {
|
const fetchItems = useCallback(async () => {
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||||
@@ -107,11 +103,7 @@ export default function DatasetPage() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return [...localCards, ...remoteCards]
|
return [...localCards, ...remoteCards]
|
||||||
}, [savedAnnotations, items, selectedFlight, statusFilter, objectsOnly, selectedClassNum, debouncedSearch, fromDate, toDate])
|
}, [savedAnnotations, items, selectedFlight, statusFilter, selectedClassNum, debouncedSearch, fromDate, toDate])
|
||||||
|
|
||||||
const [editorFullFrame, setEditorFullFrame] = useState<string>('')
|
|
||||||
const [editorLocalGroupId, setEditorLocalGroupId] = useState<string | null>(null)
|
|
||||||
const [editorSaving, setEditorSaving] = useState(false)
|
|
||||||
|
|
||||||
const handleDoubleClick = async (card: DatasetCard) => {
|
const handleDoubleClick = async (card: DatasetCard) => {
|
||||||
if (card.isLocal && card.detections && card.mediaId) {
|
if (card.isLocal && card.detections && card.mediaId) {
|
||||||
@@ -151,7 +143,7 @@ export default function DatasetPage() {
|
|||||||
const existing = savedAnnotations.find(s => s.annotationLocalId === editorLocalGroupId)
|
const existing = savedAnnotations.find(s => s.annotationLocalId === editorLocalGroupId)
|
||||||
const thumbs = await recaptureThumbnails(editorFullFrame, editorDetections)
|
const thumbs = await recaptureThumbnails(editorFullFrame, editorDetections)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const items: SavedDetection[] = editorDetections.map((d, i) => ({
|
const replacement: SavedDetection[] = editorDetections.map((d, i) => ({
|
||||||
id: `${editorLocalGroupId}:${d.id ?? i}`,
|
id: `${editorLocalGroupId}:${d.id ?? i}`,
|
||||||
annotationLocalId: editorLocalGroupId,
|
annotationLocalId: editorLocalGroupId,
|
||||||
mediaId: editorAnnotation.mediaId,
|
mediaId: editorAnnotation.mediaId,
|
||||||
@@ -165,7 +157,7 @@ export default function DatasetPage() {
|
|||||||
time: editorAnnotation.time,
|
time: editorAnnotation.time,
|
||||||
flightId: existing?.flightId ?? null,
|
flightId: existing?.flightId ?? null,
|
||||||
}))
|
}))
|
||||||
replaceGroup(editorLocalGroupId, items)
|
replaceGroup(editorLocalGroupId, replacement)
|
||||||
}
|
}
|
||||||
setTab('annotations')
|
setTab('annotations')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -196,114 +188,147 @@ export default function DatasetPage() {
|
|||||||
updateStatus(localIds, AnnotationStatus.Validated)
|
updateStatus(localIds, AnnotationStatus.Validated)
|
||||||
}
|
}
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
|
setPage(1)
|
||||||
fetchItems()
|
fetchItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDistribution = useCallback(async () => {
|
useEffect(() => {
|
||||||
try {
|
api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
|
||||||
const data = await api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
|
.then(setDistribution)
|
||||||
setDistribution(data)
|
.catch(() => {})
|
||||||
} catch {}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { if (tab === 'distribution') loadDistribution() }, [tab, loadDistribution])
|
const classCounts = useMemo(() => {
|
||||||
|
const m: Record<number, number> = {}
|
||||||
|
for (const d of distribution) m[d.classNum] = d.count
|
||||||
|
return m
|
||||||
|
}, [distribution])
|
||||||
|
|
||||||
const maxDistCount = Math.max(...distribution.map(d => d.count), 1)
|
const maxDistCount = useMemo(
|
||||||
|
() => Math.max(...distribution.map(d => d.count), 1),
|
||||||
|
[distribution],
|
||||||
|
)
|
||||||
const totalPages = Math.ceil(totalCount / pageSize)
|
const totalPages = Math.ceil(totalCount / pageSize)
|
||||||
|
const relevantSavedCount = useMemo(() => {
|
||||||
|
if (!selectedFlight) return savedAnnotations.length
|
||||||
|
return savedAnnotations.filter(sd => !sd.flightId || sd.flightId === selectedFlight.id).length
|
||||||
|
}, [savedAnnotations, selectedFlight])
|
||||||
|
const grandTotal = totalCount + relevantSavedCount
|
||||||
|
const validatedCount = useMemo(
|
||||||
|
() => cards.filter(c => c.status === AnnotationStatus.Validated).length,
|
||||||
|
[cards],
|
||||||
|
)
|
||||||
|
|
||||||
|
const firstSelectedName = useMemo(() => {
|
||||||
|
const firstId = selectedIds.values().next().value
|
||||||
|
if (!firstId) return null
|
||||||
|
return cards.find(c => c.annotationId === firstId)?.imageName ?? null
|
||||||
|
}, [selectedIds, cards])
|
||||||
|
|
||||||
const editorMedia: Media | null = editorAnnotation ? {
|
const editorMedia: Media | null = editorAnnotation ? {
|
||||||
id: editorAnnotation.mediaId, name: '', path: editorFullFrame, mediaType: 1, mediaStatus: 0,
|
id: editorAnnotation.mediaId, name: '', path: editorFullFrame, mediaType: 1, mediaStatus: 0,
|
||||||
duration: null, annotationCount: 0, waypointId: null, userId: '',
|
duration: null, annotationCount: 0, waypointId: null, userId: '',
|
||||||
} : null
|
} : null
|
||||||
|
|
||||||
const statusButtons = [
|
|
||||||
{ label: 'All', value: null },
|
|
||||||
{ label: t('dataset.status.created'), value: AnnotationStatus.Created },
|
|
||||||
{ label: t('dataset.status.edited'), value: AnnotationStatus.Edited },
|
|
||||||
{ label: t('dataset.status.validated'), value: AnnotationStatus.Validated },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex-1 flex overflow-hidden p-3 gap-3 h-full">
|
||||||
{/* Left panel */}
|
<DatasetLeftPanel
|
||||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
|
||||||
<DetectionClasses
|
|
||||||
selectedClassNum={selectedClassNum}
|
selectedClassNum={selectedClassNum}
|
||||||
onSelect={setSelectedClassNum}
|
onSelectClass={setSelectedClassNum}
|
||||||
photoMode={photoMode}
|
classCounts={classCounts}
|
||||||
onPhotoModeChange={setPhotoMode}
|
objectsOnly={objectsOnly}
|
||||||
|
onObjectsOnlyChange={setObjectsOnly}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
totalCount={grandTotal}
|
||||||
|
validatedCount={validatedCount}
|
||||||
/>
|
/>
|
||||||
<div className="p-2 border-t border-az-border">
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-az-text cursor-pointer">
|
<main className="flex-1 min-w-0 flex flex-col gap-3">
|
||||||
<input type="checkbox" checked={objectsOnly} onChange={e => setObjectsOnly(e.target.checked)} className="accent-az-orange" />
|
<DatasetFilterBar
|
||||||
{t('dataset.objectsOnly')}
|
fromDate={fromDate}
|
||||||
</label>
|
toDate={toDate}
|
||||||
</div>
|
onFromDateChange={setFromDate}
|
||||||
<div className="p-2 border-t border-az-border">
|
onToDateChange={setToDate}
|
||||||
<input
|
statusFilter={statusFilter}
|
||||||
value={search}
|
onStatusFilterChange={s => { setStatusFilter(s); setPage(1) }}
|
||||||
onChange={e => setSearch(e.target.value)}
|
flightName={selectedFlight?.name ?? null}
|
||||||
placeholder={t('dataset.search')}
|
shownCount={cards.length}
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
totalCount={grandTotal}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
|
||||||
|
|
||||||
{/* Main area */}
|
<div className="bracket panel relative flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
<span className="br" />
|
||||||
{/* Filter bar */}
|
|
||||||
<div className="flex items-center gap-2 p-2 border-b border-az-border bg-az-panel text-xs flex-wrap">
|
|
||||||
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" />
|
|
||||||
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" />
|
|
||||||
{statusButtons.map(sb => (
|
|
||||||
<button
|
|
||||||
key={String(sb.value)}
|
|
||||||
onClick={() => { setStatusFilter(sb.value); setPage(1) }}
|
|
||||||
className={`px-2 py-0.5 rounded ${statusFilter === sb.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`}
|
|
||||||
>
|
|
||||||
{sb.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<div className="flex-1" />
|
|
||||||
{selectedIds.size > 0 && (
|
|
||||||
<button onClick={handleValidate} className="bg-az-green text-white px-2 py-0.5 rounded">
|
|
||||||
{t('dataset.validate')} ({selectedIds.size})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tab strip */}
|
||||||
<div className="flex border-b border-az-border bg-az-panel">
|
<div className="flex items-center px-2 border-b border-border-hair shrink-0">
|
||||||
{(['annotations', 'editor', 'distribution'] as Tab[]).map(tb => (
|
|
||||||
<button
|
<button
|
||||||
key={tb}
|
type="button"
|
||||||
onClick={() => setTab(tb)}
|
onClick={() => setTab('annotations')}
|
||||||
className={`px-3 py-1.5 text-xs ${tab === tb ? 'bg-az-bg text-white border-b-2 border-az-orange' : 'text-az-muted'}`}
|
className={`tab ${tab === 'annotations' ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
{t(`dataset.${tb === 'distribution' ? 'classDistribution' : tb}`)}
|
<span>{t('dataset.annotations')}</span>
|
||||||
|
<span
|
||||||
|
className={`ml-1.5 px-1.5 py-px text-[10px] font-mono border rounded-[2px] tabular-nums ${
|
||||||
|
tab === 'annotations'
|
||||||
|
? 'text-accent-amber border-accent-amber'
|
||||||
|
: 'text-text-muted border-border-hair'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cards.length}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('editor')}
|
||||||
|
className={`tab ${tab === 'editor' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<span>{t('dataset.editor')}</span>
|
||||||
|
<span
|
||||||
|
className={`ml-1.5 px-1.5 py-px text-[10px] font-mono border rounded-[2px] tabular-nums ${
|
||||||
|
tab === 'editor'
|
||||||
|
? 'text-accent-amber border-accent-amber'
|
||||||
|
: 'text-text-muted border-border-hair'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{editorAnnotation ? editorDetections.length : '—'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('distribution')}
|
||||||
|
className={`tab ${tab === 'distribution' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<span>{t('dataset.classDistribution')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="ml-auto flex items-center gap-2 px-2 micro"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<span className="live-dot" />
|
||||||
|
<span>{t('dataset.liveSync')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{tab === 'annotations' && (
|
{tab === 'annotations' && (
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
|
|
||||||
{cards.map(card => {
|
|
||||||
const statusPill =
|
|
||||||
card.status === AnnotationStatus.Validated ? { cls: 'bg-az-green text-white', label: t('dataset.status.validated') } :
|
|
||||||
card.status === AnnotationStatus.Edited ? { cls: 'bg-az-blue text-white', label: t('dataset.status.edited') } :
|
|
||||||
{ cls: 'bg-az-orange text-white', label: t('dataset.status.created') }
|
|
||||||
const isSelected = selectedIds.has(card.annotationId)
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
|
className="grid gap-2"
|
||||||
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))' }}
|
||||||
|
>
|
||||||
|
{cards.map(card => (
|
||||||
|
<DatasetTile
|
||||||
key={card.annotationId}
|
key={card.annotationId}
|
||||||
|
card={card}
|
||||||
|
isSelected={selectedIds.has(card.annotationId)}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (e.ctrlKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
setSelectedIds(prev => {
|
setSelectedIds(prev => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev)
|
||||||
n.has(card.annotationId) ? n.delete(card.annotationId) : n.add(card.annotationId)
|
if (n.has(card.annotationId)) n.delete(card.annotationId)
|
||||||
|
else n.add(card.annotationId)
|
||||||
return n
|
return n
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -316,50 +341,34 @@ export default function DatasetPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
removeSaved(card.annotationId)
|
removeSaved(card.annotationId)
|
||||||
}}
|
}}
|
||||||
title={card.imageName}
|
onEditClick={() => handleDoubleClick(card)}
|
||||||
className={`aspect-square bg-az-panel rounded border overflow-hidden cursor-pointer relative transition-colors ${
|
|
||||||
isSelected ? 'border-az-orange' : 'border-az-border hover:border-az-blue'
|
|
||||||
} ${card.isSeed ? 'ring-2 ring-az-red' : ''}`}
|
|
||||||
>
|
|
||||||
{card.thumbnailUrl ? (
|
|
||||||
<img
|
|
||||||
src={card.thumbnailUrl}
|
|
||||||
alt={card.imageName}
|
|
||||||
className="w-full h-full object-cover bg-az-bg"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
))}
|
||||||
<div className="w-full h-full bg-az-bg" />
|
|
||||||
)}
|
|
||||||
<span className={`absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full ${statusPill.cls}`}>
|
|
||||||
{statusPill.label}
|
|
||||||
</span>
|
|
||||||
{card.isLocal && (
|
|
||||||
<span className="absolute top-1.5 right-1.5 text-[9px] px-1.5 py-0.5 rounded bg-az-border text-az-text">
|
|
||||||
local
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={e => { e.stopPropagation(); handleDoubleClick(card) }}
|
|
||||||
title={t('dataset.edit') ?? 'Edit'}
|
|
||||||
className="absolute bottom-1.5 right-1.5 w-6 h-6 flex items-center justify-center rounded bg-az-bg/80 text-az-text hover:bg-az-orange hover:text-white"
|
|
||||||
>
|
|
||||||
<FaPen size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
{cards.length === 0 && (
|
{cards.length === 0 && (
|
||||||
<div className="text-center text-az-muted text-xs py-8">{t('common.noData')}</div>
|
<div className="text-center text-text-muted text-xs py-8">{t('common.noData')}</div>
|
||||||
)}
|
)}
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex justify-center gap-2 py-3">
|
<div className="flex justify-center items-center gap-3 py-3">
|
||||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="text-xs text-az-muted disabled:opacity-30 px-2 py-1 bg-az-panel rounded">Prev</button>
|
<button
|
||||||
<span className="text-xs text-az-text py-1">{page} / {totalPages}</span>
|
type="button"
|
||||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages} className="text-xs text-az-muted disabled:opacity-30 px-2 py-1 bg-az-panel rounded">Next</button>
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span className="mono text-[12px] text-text-primary tabular-nums">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -368,26 +377,28 @@ export default function DatasetPage() {
|
|||||||
{tab === 'editor' && editorMedia && editorAnnotation && (
|
{tab === 'editor' && editorMedia && editorAnnotation && (
|
||||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||||
<div className="absolute inset-0 flex flex-col">
|
<div className="absolute inset-0 flex flex-col">
|
||||||
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
|
<div className="bg-surface-1 border-b border-border-hair px-3 py-2 flex gap-2 items-center shrink-0">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
onClick={handleEditorSave}
|
onClick={handleEditorSave}
|
||||||
disabled={editorSaving || (!editorLocalGroupId && editorDetections.length === 0)}
|
disabled={editorSaving || (!editorLocalGroupId && editorDetections.length === 0)}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{editorSaving ? 'Saving…' : t('common.save') ?? 'Save'}
|
{editorSaving ? 'Saving…' : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
onClick={handleEditorCancel}
|
onClick={handleEditorCancel}
|
||||||
disabled={editorSaving}
|
disabled={editorSaving}
|
||||||
className="px-2.5 py-1 rounded border border-az-border text-az-text text-[11px] hover:bg-az-border/30 disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
{t('common.cancel') ?? 'Cancel'}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-az-muted text-[10px]">
|
<span className="micro" style={{ color: 'var(--text-muted)' }}>
|
||||||
{editorDetections.length} detection{editorDetections.length !== 1 ? 's' : ''}
|
{editorDetections.length} detection{editorDetections.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
{!editorLocalGroupId && (
|
{!editorLocalGroupId && (
|
||||||
<span className="text-az-muted text-[10px] ml-auto">
|
<span className="micro ml-auto" style={{ color: 'var(--text-muted)' }}>
|
||||||
remote save not wired yet
|
remote save not wired yet
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -410,25 +421,41 @@ export default function DatasetPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'distribution' && (
|
{tab === 'distribution' && (
|
||||||
<div className="flex-1 overflow-y-auto bg-az-bg">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{distribution.map(d => {
|
{distribution.map(d => {
|
||||||
const pct = (d.count / maxDistCount) * 100
|
const pct = (d.count / maxDistCount) * 100
|
||||||
return (
|
return (
|
||||||
<div key={d.classNum} className="relative h-6 border-b border-az-border/40">
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 left-0"
|
key={d.classNum}
|
||||||
style={{ width: `${pct}%`, backgroundColor: d.color, opacity: 0.85 }}
|
className="relative flex items-center h-8 border-b border-border-hair px-3 gap-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 pointer-events-none"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: d.color, opacity: 0.18 }}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex items-center justify-between h-full px-2 text-xs text-white tabular-nums">
|
<span className="swatch shrink-0 relative" style={{ background: d.color }} />
|
||||||
<span className="truncate">{d.label}: {d.count}</span>
|
<span className="relative text-[12px] text-text-primary truncate">{d.label}</span>
|
||||||
<span className="pl-2">{d.count}</span>
|
<span className="relative ml-auto mono text-[12px] text-text-primary tabular-nums">
|
||||||
</div>
|
{d.count.toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{distribution.length === 0 && (
|
||||||
|
<div className="text-center text-text-muted text-xs py-8">{t('common.noData')}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DatasetStatusBar
|
||||||
|
selectedCount={selectedIds.size}
|
||||||
|
totalShown={cards.length}
|
||||||
|
firstSelectedName={firstSelectedName}
|
||||||
|
canValidate={selectedIds.size > 0}
|
||||||
|
onValidate={handleValidate}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface DatasetStatusBarProps {
|
||||||
|
selectedCount: number
|
||||||
|
totalShown: number
|
||||||
|
firstSelectedName: string | null
|
||||||
|
canValidate: boolean
|
||||||
|
onValidate: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasetStatusBar({
|
||||||
|
selectedCount,
|
||||||
|
totalShown,
|
||||||
|
firstSelectedName,
|
||||||
|
canValidate,
|
||||||
|
onValidate,
|
||||||
|
}: DatasetStatusBarProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bracket panel relative flex items-center gap-3 px-3 shrink-0" style={{ height: 44 }}>
|
||||||
|
<span className="br" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!canValidate}
|
||||||
|
onClick={onValidate}
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{t('dataset.validate')} ({selectedCount})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="w-px h-5 bg-border-hair shrink-0" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="micro">{t('dataset.selected')}</span>
|
||||||
|
<span className="mono text-[12px] text-text-primary truncate">
|
||||||
|
{firstSelectedName ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<span className="text-[11px] text-text-muted">
|
||||||
|
{t('dataset.ofSelected', { count: selectedCount, total: totalShown })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { FaPen } from 'react-icons/fa'
|
||||||
|
import { AnnotationStatus } from '../../types'
|
||||||
|
import type { Detection } from '../../types'
|
||||||
|
|
||||||
|
export interface DatasetCard {
|
||||||
|
annotationId: string
|
||||||
|
imageName: string
|
||||||
|
status: AnnotationStatus
|
||||||
|
createdDate: string
|
||||||
|
thumbnailUrl: string
|
||||||
|
isSeed: boolean
|
||||||
|
isLocal: boolean
|
||||||
|
detections?: Detection[]
|
||||||
|
mediaId?: string
|
||||||
|
time?: string | null
|
||||||
|
fullFrame?: string
|
||||||
|
annotationLocalId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DatasetTileProps {
|
||||||
|
card: DatasetCard
|
||||||
|
isSelected: boolean
|
||||||
|
onClick: (e: React.MouseEvent) => void
|
||||||
|
onDoubleClick: () => void
|
||||||
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
|
onEditClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TILE_DATE_FMT = new Intl.DateTimeFormat('en', { day: '2-digit', month: 'short' })
|
||||||
|
|
||||||
|
export function formatTileDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (isNaN(d.getTime())) return ''
|
||||||
|
return TILE_DATE_FMT.format(d).toUpperCase()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasetTile({
|
||||||
|
card,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
onDoubleClick,
|
||||||
|
onContextMenu,
|
||||||
|
onEditClick,
|
||||||
|
}: DatasetTileProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const statusPill =
|
||||||
|
card.status === AnnotationStatus.Validated
|
||||||
|
? { cls: 'pill-green', label: t('dataset.status.validated') }
|
||||||
|
: card.status === AnnotationStatus.Edited
|
||||||
|
? { cls: 'pill-blue', label: t('dataset.status.edited') }
|
||||||
|
: card.status === AnnotationStatus.Created
|
||||||
|
? { cls: 'pill-amber', label: t('dataset.status.created') }
|
||||||
|
: { cls: 'pill-muted', label: t('dataset.status.none') }
|
||||||
|
|
||||||
|
const borderCls = isSelected
|
||||||
|
? card.isSeed
|
||||||
|
? 'border-2 border-accent-amber ring-1 ring-accent-red'
|
||||||
|
: 'border-2 border-accent-amber'
|
||||||
|
: card.isSeed
|
||||||
|
? 'border border-accent-red'
|
||||||
|
: 'border border-border-hair hover:border-accent-amber'
|
||||||
|
|
||||||
|
const tileDate = formatTileDate(card.createdDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
title={card.imageName}
|
||||||
|
className={`group aspect-square relative overflow-hidden rounded-[2px] bg-surface-1 cursor-pointer transition-colors ${borderCls}`}
|
||||||
|
>
|
||||||
|
{card.thumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
src={card.thumbnailUrl}
|
||||||
|
alt={card.imageName}
|
||||||
|
loading="lazy"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover bg-surface-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-surface-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* composite scrim: grid lines + bottom fade (matches design .tile .scrim) */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),' +
|
||||||
|
'linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px),' +
|
||||||
|
'linear-gradient(180deg, rgba(0,0,0,0) 55%, rgba(0,0,0,0.55) 100%)',
|
||||||
|
backgroundSize: '24px 24px, 24px 24px, 100% 100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* corner-tag top-right */}
|
||||||
|
{tileDate && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1.5 right-1.5 font-mono text-[9px] tracking-wider text-text-primary border border-border-hair rounded-[2px]"
|
||||||
|
style={{ background: 'rgba(10,13,16,0.65)', padding: '1px 5px' }}
|
||||||
|
>
|
||||||
|
{tileDate}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* local badge — top-left */}
|
||||||
|
{card.isLocal && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1.5 left-1.5 font-mono text-[9px] tracking-wider text-accent-cyan border border-accent-cyan/50 rounded-[2px] px-1.5 py-px"
|
||||||
|
style={{ background: 'rgba(10,13,16,0.65)' }}
|
||||||
|
>
|
||||||
|
{t('dataset.local').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* selected check badge (only when selected & not local — local already has top-left badge) */}
|
||||||
|
{isSelected && !card.isLocal && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1 left-1 inline-flex items-center justify-center rounded-[2px] bg-accent-amber"
|
||||||
|
style={{ width: 14, height: 14, color: '#0A0D10' }}
|
||||||
|
>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* status pill bottom-left */}
|
||||||
|
<span
|
||||||
|
className={`absolute bottom-1.5 left-1.5 pill ${statusPill.cls}`}
|
||||||
|
style={{ padding: '2px 6px', fontSize: 9, height: 'auto', lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
<span className="dot" />
|
||||||
|
{statusPill.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* edit ibtn bottom-right (reveal on hover) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => { e.stopPropagation(); onEditClick() }}
|
||||||
|
title={t('dataset.edit')}
|
||||||
|
className="ibtn edit absolute bottom-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ background: 'rgba(10,13,16,0.65)' }}
|
||||||
|
>
|
||||||
|
<FaPen size={9} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@ export default function AltitudeChart({ points }: Props) {
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
label: t('flights.planner.altitude'),
|
label: t('flights.planner.altitude'),
|
||||||
data: points.map(p => p.altitude),
|
data: points.map(p => p.altitude),
|
||||||
borderColor: '#228be6',
|
borderColor: '#36D6C5',
|
||||||
backgroundColor: 'rgba(34,139,230,0.2)',
|
backgroundColor: 'rgba(54,214,197,0.18)',
|
||||||
pointBackgroundColor: '#fd7e14',
|
pointBackgroundColor: '#FF9D3D',
|
||||||
pointBorderColor: '#1e1e1e',
|
pointBorderColor: '#1e1e1e',
|
||||||
pointBorderWidth: 1,
|
pointBorderWidth: 1,
|
||||||
tension: 0.1,
|
tension: 0.1,
|
||||||
@@ -31,8 +31,8 @@ export default function AltitudeChart({ points }: Props) {
|
|||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { font: { size: 10 }, color: '#6c757d' }, grid: { color: '#495057' } },
|
x: { ticks: { font: { size: 10 }, color: '#9AA4B2' }, grid: { color: 'rgba(255,255,255,0.06)' } },
|
||||||
y: { ticks: { font: { size: 10 }, color: '#6c757d' }, grid: { color: '#495057' } },
|
y: { ticks: { font: { size: 10 }, color: '#9AA4B2' }, grid: { color: 'rgba(255,255,255,0.06)' } },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,46 +34,56 @@ export default function AltitudeDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[2000]">
|
<div className="fixed inset-0 flex items-center justify-center z-[2000]" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
||||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 shadow-xl">
|
<div className="bracket panel w-96 shadow-xl" style={{ background: 'var(--surface-1)', padding: '20px' }}>
|
||||||
<h3 className="text-white font-semibold mb-1">
|
<h3 className="sect-head mb-1">
|
||||||
{isEditMode ? t('flights.planner.titleEdit') : t('flights.planner.titleAdd')}
|
{isEditMode ? t('flights.planner.titleEdit') : t('flights.planner.titleAdd')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-az-muted text-xs mb-3">{t('flights.planner.description')}</p>
|
<p className="micro mb-4" style={{ textTransform: 'none', letterSpacing: 'normal', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.planner.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-0.5">{t('flights.planner.latitude')}</label>
|
<label className="micro block mb-1">{t('flights.planner.latitude')}</label>
|
||||||
<input type="number" step="any"
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
value={latitude.toFixed(COORDINATE_PRECISION)}
|
value={latitude.toFixed(COORDINATE_PRECISION)}
|
||||||
onChange={e => handleCoord(e.target.value, onLatitudeChange)}
|
onChange={e => handleCoord(e.target.value, onLatitudeChange)}
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1.5 text-az-text outline-none focus:border-az-orange"
|
className="inp inp-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-0.5">{t('flights.planner.longitude')}</label>
|
<label className="micro block mb-1">{t('flights.planner.longitude')}</label>
|
||||||
<input type="number" step="any"
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
value={longitude.toFixed(COORDINATE_PRECISION)}
|
value={longitude.toFixed(COORDINATE_PRECISION)}
|
||||||
onChange={e => handleCoord(e.target.value, onLongitudeChange)}
|
onChange={e => handleCoord(e.target.value, onLongitudeChange)}
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1.5 text-az-text outline-none focus:border-az-orange"
|
className="inp inp-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-0.5">{t('flights.planner.altitude')}</label>
|
<label className="micro block mb-1">{t('flights.planner.altitude')}</label>
|
||||||
<input type="number"
|
<input
|
||||||
|
type="number"
|
||||||
value={altitude}
|
value={altitude}
|
||||||
onChange={e => onAltitudeChange(Number(e.target.value))}
|
onChange={e => onAltitudeChange(Number(e.target.value))}
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1.5 text-az-text outline-none focus:border-az-orange"
|
className="inp inp-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-1">{t('flights.planner.purpose')}</label>
|
<label className="micro block mb-2">{t('flights.planner.purpose')}</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-4">
|
||||||
{PURPOSES.map(p => (
|
{PURPOSES.map(p => (
|
||||||
<label key={p.value} className="flex items-center gap-1.5 cursor-pointer text-az-text">
|
<label key={p.value} className="flex items-center gap-1.5 cursor-pointer text-text-primary text-[12px]">
|
||||||
<input type="checkbox" checked={meta.includes(p.value)}
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={meta.includes(p.value)}
|
||||||
onChange={() => toggleMeta(p.value)}
|
onChange={() => toggleMeta(p.value)}
|
||||||
className="rounded border-az-border bg-az-bg accent-az-orange" />
|
style={{ accentColor: 'var(--accent-amber)' }}
|
||||||
|
/>
|
||||||
{t(`flights.planner.${p.label}`)}
|
{t(`flights.planner.${p.label}`)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -81,16 +91,16 @@ export default function AltitudeDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
<button onClick={onClose}
|
<button onClick={onClose} className="btn btn-ghost">
|
||||||
className="px-3 py-1 text-sm border border-az-border rounded hover:bg-az-bg text-az-text">
|
|
||||||
{t('flights.planner.cancel')}
|
{t('flights.planner.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onSubmit}
|
<button onClick={onSubmit} className="btn btn-primary">
|
||||||
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')}
|
{isEditMode ? t('flights.planner.submitEdit') : t('flights.planner.submitAdd')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span className="br" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function FlightListSidebar({ flights, selectedFlight, onSelect, o
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
const name = newName.trim()
|
const name = newName.trim()
|
||||||
@@ -28,47 +29,126 @@ export default function FlightListSidebar({ flights, selectedFlight, onSelect, o
|
|||||||
setCreating(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needle = search.trim().toLowerCase()
|
||||||
|
const filteredFlights = needle
|
||||||
|
? flights.filter(f => f.name.toLowerCase().includes(needle))
|
||||||
|
: flights
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-az-panel border-r border-az-border flex flex-col shrink-0 w-[160px]">
|
<div className="w-[210px] shrink-0 flex flex-col border-r border-border-hair bg-surface-1">
|
||||||
<div className="px-2 py-2 border-b border-az-border text-[10px] text-az-muted uppercase tracking-wide">
|
|
||||||
{t('flights.title')}
|
{/* Header */}
|
||||||
|
<div className="px-3 py-2.5 flex items-center justify-between border-b border-border-hair">
|
||||||
|
<span className="sect-head">{t('flights.v2.roster')}</span>
|
||||||
|
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{String(flights.length).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="px-3 py-2 border-b border-border-hair">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className="inp mono text-[11px]"
|
||||||
|
style={{ height: 28, letterSpacing: '0.08em', paddingLeft: 28 }}
|
||||||
|
placeholder={t('flights.v2.search')}
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2"
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flight list */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{flights.map(f => (
|
{filteredFlights.map(f => {
|
||||||
<div key={f.id} onClick={() => onSelect(f)}
|
const isActive = selectedFlight?.id === f.id
|
||||||
className={`px-2 py-1.5 cursor-pointer border-b border-az-border text-xs ${
|
return (
|
||||||
selectedFlight?.id === f.id ? 'bg-az-bg text-white' : 'text-az-text hover:bg-az-bg'
|
<div
|
||||||
}`}>
|
key={f.id}
|
||||||
<div className="flex items-center justify-between">
|
onClick={() => onSelect(f)}
|
||||||
<span className="truncate">{f.name}</span>
|
className={`group relative flex items-center gap-2 cursor-pointer border-b border-border-hair mono text-[12px]${isActive ? ' bg-surface-2' : ''}`}
|
||||||
<button onClick={e => { e.stopPropagation(); onDelete(f.id) }}
|
style={{ height: 28, padding: '0 12px' }}
|
||||||
className="text-az-muted hover:text-az-red text-xs">×</button>
|
>
|
||||||
|
{isActive && (
|
||||||
|
<span style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 2, background: 'var(--accent-amber)' }} />
|
||||||
|
)}
|
||||||
|
<span style={{ color: 'var(--accent-amber)' }} className="truncate">
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="ml-auto text-[10px]"
|
||||||
|
style={{ color: 'var(--text-muted)', letterSpacing: '0.08em' }}
|
||||||
|
>
|
||||||
|
{new Date(f.createdDate).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete(f.id) }}
|
||||||
|
className="opacity-0 group-hover:opacity-100 hover:text-accent-red text-text-muted text-[13px] leading-none shrink-0"
|
||||||
|
aria-label="Delete flight"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-az-muted">{new Date(f.createdDate).toLocaleDateString()}</div>
|
)
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create section */}
|
||||||
|
<div className="p-3 border-t border-border-hair">
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<div className="flex gap-1 mx-3 my-2">
|
<div className="flex gap-1">
|
||||||
<input autoFocus value={newName} onChange={e => setNewName(e.target.value)}
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') handleCreate()
|
if (e.key === 'Enter') handleCreate()
|
||||||
if (e.key === 'Escape') handleCancel()
|
if (e.key === 'Escape') handleCancel()
|
||||||
}}
|
}}
|
||||||
placeholder="Flight name"
|
placeholder={t('flights.v2.createNew')}
|
||||||
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" />
|
className="inp mono flex-1 min-w-0 text-[11px]"
|
||||||
<button onClick={handleCreate} className="shrink-0 bg-az-blue text-white text-xs px-3 py-1.5 rounded hover:brightness-110">OK</button>
|
style={{ height: 28 }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleCreate} className="btn btn-primary shrink-0">
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => setCreating(true)}
|
<button
|
||||||
className="mx-3 my-2 py-1.5 bg-az-blue text-white rounded text-xs hover:brightness-110">
|
onClick={() => setCreating(true)}
|
||||||
+ {t('flights.create')}
|
className="btn btn-primary w-full flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<path d="M5 1 V9 M1 5 H9" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
{t('flights.v2.createNew')}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
|
{/* Telemetry card */}
|
||||||
|
<div className="m-3 mt-0 bracket panel p-3">
|
||||||
|
<span className="br" />
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="micro" style={{ color: 'var(--accent-amber)' }}>// {t('flights.telemetry')}</span>
|
||||||
|
</div>
|
||||||
|
<label className="micro block mb-1">{t('flights.v2.date')}</label>
|
||||||
|
<input type="date" className="inp inp-mono text-[12px]" style={{ colorScheme: 'dark' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useEffect, useState } from 'react'
|
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, Rectangle, useMap, useMapEvents } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker, Popup, Rectangle, useMap, useMapEvents } from 'react-leaflet'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import 'leaflet-polylinedecorator'
|
import 'leaflet-polylinedecorator'
|
||||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import DrawControl from './DrawControl'
|
import DrawControl from './DrawControl'
|
||||||
import MapPoint from './MapPoint'
|
import MapPoint from './MapPoint'
|
||||||
import MiniMap from './MiniMap'
|
import MiniMap from './MiniMap'
|
||||||
import { defaultIcon } from './mapIcons'
|
import { currentPositionIcon } from './mapIcons'
|
||||||
import { getTileUrl } from './types'
|
import { getTileUrl } from './types'
|
||||||
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
|
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
|
||||||
|
|
||||||
@@ -35,9 +35,9 @@ function MapEvents({ points, handlePolylineClick, containerRef, onMapMove }: Map
|
|||||||
|
|
||||||
if (points.length > 1) {
|
if (points.length > 1) {
|
||||||
const positions: L.LatLngTuple[] = points.map(p => [p.position.lat, p.position.lng])
|
const positions: L.LatLngTuple[] = points.map(p => [p.position.lat, p.position.lng])
|
||||||
polylineRef.current = L.polyline(positions, { color: '#228be6', weight: 6, opacity: 0.7, lineJoin: 'round' }).addTo(map)
|
polylineRef.current = L.polyline(positions, { color: '#36D6C5', weight: 6, opacity: 0.7, lineJoin: 'round' }).addTo(map)
|
||||||
arrowRef.current = L.polylineDecorator(polylineRef.current, {
|
arrowRef.current = L.polylineDecorator(polylineRef.current, {
|
||||||
patterns: [{ offset: '10%', repeat: '40%', symbol: L.Symbol.arrowHead({ pixelSize: 12, pathOptions: { fillOpacity: 1, weight: 0, color: '#228be6' } }) }],
|
patterns: [{ offset: '10%', repeat: '40%', symbol: L.Symbol.arrowHead({ pixelSize: 12, pathOptions: { fillOpacity: 1, weight: 0, color: '#36D6C5' } }) }],
|
||||||
}).addTo(map)
|
}).addTo(map)
|
||||||
polylineRef.current.on('click', handlePolylineClick)
|
polylineRef.current.on('click', handlePolylineClick)
|
||||||
}
|
}
|
||||||
@@ -61,6 +61,12 @@ function SetView({ center }: { center: L.LatLngExpression }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) {
|
||||||
|
const m = useMap()
|
||||||
|
useEffect(() => { onReady(m) }, [m, onReady])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
points: FlightPoint[]
|
points: FlightPoint[]
|
||||||
calculatedPointInfo: CalculatedPointInfo[]
|
calculatedPointInfo: CalculatedPointInfo[]
|
||||||
@@ -77,21 +83,29 @@ interface Props {
|
|||||||
onPolylineClick: (e: L.LeafletMouseEvent) => void
|
onPolylineClick: (e: L.LeafletMouseEvent) => void
|
||||||
onPositionChange: (pos: { lat: number; lng: number }) => void
|
onPositionChange: (pos: { lat: number; lng: number }) => void
|
||||||
onMapMove: (center: L.LatLng) => void
|
onMapMove: (center: L.LatLng) => void
|
||||||
|
// v2 HUD optional props — safe defaults keep existing call sites intact
|
||||||
|
liveGps?: { lat: number; lon: number; satellites: number; status: string } | null
|
||||||
|
flightLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FlightMap({
|
export default function FlightMap({
|
||||||
points, currentPosition, rectangles, setRectangles,
|
points, currentPosition, rectangles, setRectangles,
|
||||||
rectangleColor, actionMode, onAddPoint, onUpdatePoint, onRemovePoint,
|
rectangleColor, actionMode, onAddPoint, onUpdatePoint, onRemovePoint,
|
||||||
onAltitudeChange, onMetaChange, onPolylineClick, onPositionChange, onMapMove,
|
onAltitudeChange, onMetaChange, onPolylineClick, onPositionChange, onMapMove,
|
||||||
|
liveGps = null,
|
||||||
|
flightLabel = '—',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
|
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
|
||||||
const [draggablePoints, setDraggablePoints] = useState(points)
|
const [draggablePoints, setDraggablePoints] = useState(points)
|
||||||
const polylineClickRef = useRef(false)
|
const polylineClickRef = useRef(false)
|
||||||
|
const [mapInstance, setMapInstance] = useState<L.Map | null>(null)
|
||||||
|
|
||||||
useEffect(() => { setDraggablePoints(points) }, [points])
|
useEffect(() => { setDraggablePoints(points) }, [points])
|
||||||
|
|
||||||
|
const handleMapReady = useCallback((m: L.Map) => { setMapInstance(m) }, [])
|
||||||
|
|
||||||
function ClickHandler() {
|
function ClickHandler() {
|
||||||
useMapEvents({
|
useMapEvents({
|
||||||
click(e) {
|
click(e) {
|
||||||
@@ -117,9 +131,23 @@ export default function FlightMap({
|
|||||||
setDraggablePoints(updated)
|
setDraggablePoints(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayLat = liveGps?.lat ?? currentPosition.lat
|
||||||
|
const displayLon = liveGps?.lon ?? currentPosition.lng
|
||||||
|
const satelliteCount = liveGps?.satellites ?? 12
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 relative" ref={containerRef}>
|
<div className="flex-1 relative" ref={containerRef}>
|
||||||
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
|
<MapContainer center={currentPosition} zoom={15} className="h-full w-full"
|
||||||
|
zoomControl={false} attributionControl={false}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#0F1318',
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),' +
|
||||||
|
'linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px),' +
|
||||||
|
'radial-gradient(ellipse at 30% 40%, rgba(54,214,197,0.04), transparent 60%),' +
|
||||||
|
'radial-gradient(ellipse at 80% 70%, rgba(255,157,61,0.03), transparent 65%)',
|
||||||
|
backgroundSize: '60px 60px, 60px 60px, 100% 100%, 100% 100%',
|
||||||
|
}}>
|
||||||
<ClickHandler />
|
<ClickHandler />
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url={getTileUrl()}
|
url={getTileUrl()}
|
||||||
@@ -128,6 +156,7 @@ export default function FlightMap({
|
|||||||
/>
|
/>
|
||||||
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
|
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
|
||||||
<SetView center={currentPosition} />
|
<SetView center={currentPosition} />
|
||||||
|
<MapRefCapture onReady={handleMapReady} />
|
||||||
|
|
||||||
{movingPoint && <MiniMap pointPosition={movingPoint} />}
|
{movingPoint && <MiniMap pointPosition={movingPoint} />}
|
||||||
|
|
||||||
@@ -144,16 +173,8 @@ export default function FlightMap({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{draggablePoints.length > 1 && (
|
|
||||||
<Polyline
|
|
||||||
positions={[[draggablePoints[draggablePoints.length - 1].position.lat, draggablePoints[draggablePoints.length - 1].position.lng],
|
|
||||||
[draggablePoints[0].position.lat, draggablePoints[0].position.lng]]}
|
|
||||||
color="#228be6" dashArray="5,10"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPosition && (
|
{currentPosition && (
|
||||||
<Marker position={currentPosition} icon={defaultIcon} draggable
|
<Marker position={currentPosition} icon={currentPositionIcon} draggable
|
||||||
eventHandlers={{ dragend: (e) => onPositionChange((e.target as L.Marker).getLatLng()) }}>
|
eventHandlers={{ dragend: (e) => onPositionChange((e.target as L.Marker).getLatLng()) }}>
|
||||||
<Popup>{t('flights.planner.currentLocation')}</Popup>
|
<Popup>{t('flights.planner.currentLocation')}</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
@@ -166,11 +187,227 @@ export default function FlightMap({
|
|||||||
<DrawControl color={rectangleColor} actionMode={actionMode} rectangles={rectangles} setRectangles={setRectangles} />
|
<DrawControl color={rectangleColor} actionMode={actionMode} rectangles={rectangles} setRectangles={setRectangles} />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* v2 drawing-hint HUD — restyled to v2 tokens */}
|
||||||
{(actionMode === 'workArea' || actionMode === 'prohibitedArea') && (
|
{(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">
|
<div
|
||||||
Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'}
|
className="top-2 left-1/2 -translate-x-1/2 bracket panel micro pointer-events-none"
|
||||||
|
style={{ position: 'absolute', zIndex: 500, padding: '4px 12px', color: 'var(--accent-amber)', background: 'rgba(19,23,28,0.92)' }}
|
||||||
|
>
|
||||||
|
{t(actionMode === 'workArea' ? 'flights.v2.drawHintWork' : 'flights.v2.drawHintNoGo')}
|
||||||
|
<span className="br" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* Compass rosette — top-left */}
|
||||||
|
{/* ======================================================= */}
|
||||||
|
<div
|
||||||
|
className="bracket panel flex items-center justify-center pointer-events-none"
|
||||||
|
style={{ position: 'absolute', top: 48, left: 16, width: 80, height: 80, background: 'rgba(19,23,28,0.6)', backdropFilter: 'blur(2px)', zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<svg width="60" height="60" viewBox="-30 -30 60 60" style={{ color: 'var(--accent-amber)' }}>
|
||||||
|
<circle r="24" fill="none" stroke="currentColor" strokeOpacity="0.3" strokeWidth="0.7" />
|
||||||
|
<circle r="20" fill="none" stroke="currentColor" strokeOpacity="0.2" strokeWidth="0.5" />
|
||||||
|
<line x1="0" y1="-26" x2="0" y2="-20" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<line x1="0" y1="20" x2="0" y2="26" stroke="currentColor" strokeOpacity="0.4" strokeWidth="0.8" />
|
||||||
|
<line x1="-26" y1="0" x2="-20" y2="0" stroke="currentColor" strokeOpacity="0.4" strokeWidth="0.8" />
|
||||||
|
<line x1="20" y1="0" x2="26" y2="0" stroke="currentColor" strokeOpacity="0.4" strokeWidth="0.8" />
|
||||||
|
<text x="0" y="-12" textAnchor="middle" fontFamily="JetBrains Mono" fontSize="7" fill="currentColor" fontWeight="700">N</text>
|
||||||
|
<polygon points="0,-16 -3,-8 0,-10 3,-8" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* Telemetry HUD — top-right */}
|
||||||
|
{/* ======================================================= */}
|
||||||
|
<div
|
||||||
|
className="bracket panel"
|
||||||
|
style={{ position: 'absolute', top: 16, right: 16, width: 240, background: 'rgba(19,23,28,0.92)', backdropFilter: 'blur(4px)', padding: 12, zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
style={{ marginBottom: 10, paddingBottom: 8, borderBottom: '1px solid var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-2 mono"
|
||||||
|
style={{ fontSize: 10, color: 'var(--accent-cyan)', letterSpacing: '0.14em' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full live"
|
||||||
|
style={{ background: 'var(--accent-cyan)' }}
|
||||||
|
/>
|
||||||
|
{t('flights.v2.hud.liveConnected')}
|
||||||
|
</span>
|
||||||
|
<span className="micro" style={{ color: 'var(--text-muted)' }}>{flightLabel}</span>
|
||||||
|
</header>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.sat')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--accent-green)' }}>{satelliteCount} / 14</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.lat')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>{displayLat.toFixed(5)}° N</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.lon')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>{displayLon.toFixed(5)}° E</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.alt')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>320 M / AGL</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.hdg')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--accent-amber)' }}>047° NE</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.spd')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>11.4 M/S</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
style={{ paddingTop: 6, marginTop: 6, borderTop: '1px solid var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<span className="micro">{t('flights.v2.hud.link')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: 'var(--accent-green)' }}>RSSI -52 DBM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* Legend — bottom-left */}
|
||||||
|
{/* ======================================================= */}
|
||||||
|
<div
|
||||||
|
className="bracket panel pointer-events-none"
|
||||||
|
style={{ position: 'absolute', bottom: 48, left: 16, width: 200, background: 'rgba(19,23,28,0.92)', padding: 12, zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<header style={{ marginBottom: 8, paddingBottom: 6, borderBottom: '1px solid var(--border-hair)' }}>
|
||||||
|
<span className="sect-head">// {t('flights.v2.hud.mapLegend')}</span>
|
||||||
|
</header>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11 }}>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<svg width="22" height="6">
|
||||||
|
<line x1="0" y1="3" x2="22" y2="3" stroke="#FF4756" strokeWidth="1.5" strokeDasharray="3 3" />
|
||||||
|
</svg>
|
||||||
|
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.hud.plannedOriginal')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<svg width="22" height="6">
|
||||||
|
<line x1="0" y1="3" x2="22" y2="3" stroke="#36D6C5" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.hud.correctedLive')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5"
|
||||||
|
style={{ paddingTop: 6, borderTop: '1px solid var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 10, height: 10, background: 'var(--accent-green)', transform: 'rotate(45deg)', flexShrink: 0 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.hud.originStart')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div style={{ width: 10, height: 10, background: 'transparent', border: '1.5px solid var(--accent-cyan)', flexShrink: 0 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.hud.waypoint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div style={{ width: 11, height: 11, background: 'var(--accent-red)', clipPath: 'polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%)', flexShrink: 0 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.hud.targetFinish')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* Map toolbar — right edge */}
|
||||||
|
{/* ======================================================= */}
|
||||||
|
<div
|
||||||
|
className="absolute flex flex-col gap-1.5 pointer-events-auto"
|
||||||
|
style={{ top: '50%', right: 16, transform: 'translateY(-50%)', zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center border border-border-hair panel mono"
|
||||||
|
style={{ width: 32, height: 32, color: 'var(--text-primary)', fontSize: 16, background: 'var(--surface-1)' }}
|
||||||
|
title={t('flights.v2.hud.zoomIn')}
|
||||||
|
onClick={() => mapInstance?.zoomIn()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center border border-border-hair panel mono"
|
||||||
|
style={{ width: 32, height: 32, color: 'var(--text-primary)', fontSize: 16, background: 'var(--surface-1)' }}
|
||||||
|
title={t('flights.v2.hud.zoomOut')}
|
||||||
|
onClick={() => mapInstance?.zoomOut()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<div style={{ width: 32, height: 1, background: 'var(--border-hair)' }} />
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center border border-border-hair panel"
|
||||||
|
style={{ width: 32, height: 32, color: 'var(--accent-amber)', background: 'var(--surface-1)' }}
|
||||||
|
title={t('flights.v2.hud.recenter')}
|
||||||
|
onClick={() => mapInstance?.setView([currentPosition.lat, currentPosition.lng])}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<line x1="12" y1="2" x2="12" y2="4" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="22" />
|
||||||
|
<line x1="2" y1="12" x2="4" y2="12" />
|
||||||
|
<line x1="20" y1="12" x2="22" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center border border-border-hair panel"
|
||||||
|
style={{ width: 32, height: 32, color: 'var(--text-secondary)', background: 'var(--surface-1)' }}
|
||||||
|
title={t('flights.v2.hud.layers')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||||
|
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||||
|
<polyline points="2 17 12 22 22 17" />
|
||||||
|
<polyline points="2 12 12 17 22 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* Bottom status strip */}
|
||||||
|
{/* ======================================================= */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 flex items-center gap-4 border-t border-border-hair pointer-events-none"
|
||||||
|
style={{ bottom: 0, height: 28, padding: '0 12px', background: 'var(--surface-1)', zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<span className="pill pill-green">
|
||||||
|
<span className="dot live" />
|
||||||
|
{t('flights.v2.strip.telemetryLive')}
|
||||||
|
</span>
|
||||||
|
<span className="micro" style={{ color: 'var(--text-muted)' }}>SSE</span>
|
||||||
|
<span className="mono micro" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.strip.frame')} 12,847 / 18,400
|
||||||
|
</span>
|
||||||
|
<span className="micro" style={{ color: 'var(--text-muted)' }}>·</span>
|
||||||
|
<span className="mono micro" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{displayLat.toFixed(5)} N · {displayLon.toFixed(5)} E
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto micro" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('flights.v2.strip.lastPing')} +0.42S
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import WaypointList from './WaypointList'
|
import WaypointList from './WaypointList'
|
||||||
import AltitudeChart from './AltitudeChart'
|
import AltitudeChart from './AltitudeChart'
|
||||||
import WindEffect from './WindEffect'
|
import WindEffect from './WindEffect'
|
||||||
|
import { DRAW_MODES, DRAW_MODE_ACCENT } from './drawModes'
|
||||||
import type { FlightPoint, CalculatedPointInfo, ActionMode, WindParams } from './types'
|
import type { FlightPoint, CalculatedPointInfo, ActionMode, WindParams } from './types'
|
||||||
import type { Aircraft } from '../../types'
|
import type { Aircraft } from '../../types'
|
||||||
|
|
||||||
@@ -39,75 +41,85 @@ export default function FlightParamsPanel({
|
|||||||
onSave, onUpload, onEditJson, onExport,
|
onSave, onUpload, onEditJson, onExport,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [hoveredMode, setHoveredMode] = useState<ActionMode | null>(null)
|
||||||
|
|
||||||
const modeBtn = (mode: ActionMode, label: string, color: 'orange' | 'green' | 'red') => {
|
return (
|
||||||
|
<section className="p-4 space-y-5 flex-1 overflow-y-auto text-[12px]">
|
||||||
|
|
||||||
|
{/* Draw-mode selector */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="micro" style={{ color: 'var(--accent-amber)' }}>// {t('flights.v2.drawMode')}</span>
|
||||||
|
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>{t('flights.v2.clickToPlot')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{DRAW_MODES.map(({ mode, i18nKey, accent, icon }) => {
|
||||||
const active = actionMode === mode
|
const active = actionMode === mode
|
||||||
const colorMap = {
|
const { color, tint } = DRAW_MODE_ACCENT[accent]
|
||||||
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 (
|
return (
|
||||||
<button
|
<button key={mode} onClick={() => onActionModeChange(mode)} className="mono"
|
||||||
onClick={() => onActionModeChange(mode)}
|
onMouseEnter={() => setHoveredMode(mode)} onMouseLeave={() => setHoveredMode(null)}
|
||||||
className={`flex-1 px-2.5 py-1 rounded border text-[11px] ${colorMap.border} ${colorMap.text} ${active ? colorMap.bg : colorMap.hover}`}
|
style={{
|
||||||
>{label}</button>
|
minHeight: 32, padding: '0 8px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
|
border: `1px solid ${color}`, color, borderRadius: 2,
|
||||||
|
fontSize: 10, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase',
|
||||||
|
background: active ? tint : (hoveredMode === mode ? 'rgba(255,255,255,0.04)' : 'transparent'),
|
||||||
|
boxShadow: active ? `inset 0 0 0 1px ${color}` : 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<span style={{ flexShrink: 0, display: 'inline-flex' }}>{icon}</span>
|
||||||
|
<span style={{ textAlign: 'center', lineHeight: 1.1 }}>{t(i18nKey)}</span>
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
})}
|
||||||
|
</div>
|
||||||
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>
|
||||||
|
|
||||||
|
{/* Mission Config */}
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<h2 className="sect-head">{t('flights.v2.missionConfig')}</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="bracket panel p-3 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.location')}</label>
|
<label className="micro block mb-1.5">{t('flights.v2.aircraft')}</label>
|
||||||
<input
|
<select className="inp">
|
||||||
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>)}
|
{aircrafts.map(a => <option key={a.id} value={a.id}>{a.model}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.initialAltitude')}</label>
|
<label className="micro block mb-1.5">{t('flights.v2.defaultHeight')}</label>
|
||||||
|
<div className="relative">
|
||||||
<input type="number" value={initialAltitude}
|
<input type="number" value={initialAltitude}
|
||||||
onChange={e => onInitialAltitudeChange(Number(e.target.value))}
|
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"
|
className="inp inp-mono" style={{ paddingRight: 36 }} />
|
||||||
/>
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 micro" style={{ color: 'var(--text-muted)' }}>M</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.v2.focalLength')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input type="text" placeholder={t('flights.planner.cameraFovPlaceholder')} className="inp inp-mono" style={{ paddingRight: 40 }} />
|
||||||
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 micro" style={{ color: 'var(--text-muted)' }}>MM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.v2.commAddr')}</label>
|
||||||
|
<input type="text" placeholder={t('flights.planner.commAddrPlaceholder')} className="inp inp-mono" />
|
||||||
|
</div>
|
||||||
|
<span className="br" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Waypoints */}
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.cameraFov')}</label>
|
<div className="bracket panel p-3">
|
||||||
<input type="text" placeholder={t('flights.planner.cameraFovPlaceholder')}
|
<header className="flex items-center justify-between mb-2.5">
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text outline-none focus:border-az-orange"
|
<span className="sect-head">{t('flights.waypoints')}</span>
|
||||||
/>
|
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>
|
||||||
</div>
|
{String(points.length).padStart(2, '0')} {t('flights.v2.pts')}
|
||||||
|
</span>
|
||||||
<div>
|
</header>
|
||||||
<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
|
<WaypointList
|
||||||
points={points}
|
points={points}
|
||||||
calculatedPointInfo={calculatedPointInfo}
|
calculatedPointInfo={calculatedPointInfo}
|
||||||
@@ -115,13 +127,32 @@ export default function FlightParamsPanel({
|
|||||||
onEdit={onEditPoint}
|
onEdit={onEditPoint}
|
||||||
onRemove={onRemovePoint}
|
onRemove={onRemovePoint}
|
||||||
/>
|
/>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Existing controls (restyled, appended below mockup blocks) ── */}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.planner.location')}</label>
|
||||||
|
<input
|
||||||
|
value={locationInput}
|
||||||
|
onChange={e => onLocationInputChange(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && onLocationSearch()}
|
||||||
|
placeholder="47.242, 35.024"
|
||||||
|
className="inp inp-mono"
|
||||||
|
/>
|
||||||
|
<div className="micro mt-1" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('flights.planner.currentLocation')}: {currentPosition.lat.toFixed(6)}, {currentPosition.lng.toFixed(6)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{points.length > 1 && (
|
{points.length > 1 && (
|
||||||
<div className="bg-az-header rounded px-2 py-1 flex gap-2 text-[10px]">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span>{totalDistance}</span>
|
<span className="pill pill-muted">{totalDistance}</span>
|
||||||
<span>{totalTime}</span>
|
<span className="pill pill-muted">{totalTime}</span>
|
||||||
<span style={{ color: batteryStatus.color }}>{batteryStatus.label}</span>
|
<span className="pill" style={{ color: batteryStatus.color }}>
|
||||||
|
<span className="dot" />{batteryStatus.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -129,22 +160,16 @@ export default function FlightParamsPanel({
|
|||||||
|
|
||||||
<WindEffect wind={wind} onChange={onWindChange} />
|
<WindEffect wind={wind} onChange={onWindChange} />
|
||||||
|
|
||||||
<div className="flex gap-1">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<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">
|
<button onClick={onSave} className="btn btn-secondary justify-center" style={{ color: 'var(--accent-green)', borderColor: 'var(--accent-green)' }}>
|
||||||
{t('flights.planner.save')}
|
{t('flights.planner.save')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onUpload} className="flex-1 px-2.5 py-1 rounded border border-az-blue text-az-blue text-[11px] hover:bg-az-blue/10">
|
<button onClick={onUpload} className="btn btn-secondary justify-center" style={{ color: 'var(--accent-cyan)', borderColor: 'var(--accent-cyan)' }}>
|
||||||
{t('flights.planner.upload')}
|
{t('flights.planner.upload')}
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={onEditJson} className="btn btn-ghost justify-center">{t('flights.planner.editAsJson')}</button>
|
||||||
|
<button onClick={onExport} className="btn btn-ghost justify-center">{t('flights.planner.exportMapData')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
</section>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,19 @@ import FlightParamsPanel from './FlightParamsPanel'
|
|||||||
import FlightMap from './FlightMap'
|
import FlightMap from './FlightMap'
|
||||||
import AltitudeDialog from './AltitudeDialog'
|
import AltitudeDialog from './AltitudeDialog'
|
||||||
import JsonEditorDialog from './JsonEditorDialog'
|
import JsonEditorDialog from './JsonEditorDialog'
|
||||||
|
import GpsDeniedPanel from './GpsDeniedPanel'
|
||||||
|
import { DRAW_MODES, DRAW_MODE_ACCENT } from './drawModes'
|
||||||
import { newGuid, calculateDistance, calculateAllPoints, parseCoordinates, getMockAircraftParams } from './flightPlanUtils'
|
import { newGuid, calculateDistance, calculateAllPoints, parseCoordinates, getMockAircraftParams } from './flightPlanUtils'
|
||||||
import { PURPOSES } from './types'
|
import { PURPOSES } from './types'
|
||||||
import type { Aircraft, Waypoint } from '../../types'
|
import type { Aircraft, Waypoint } from '../../types'
|
||||||
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, WindParams, AircraftParams } from './types'
|
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, WindParams, AircraftParams, OrthoPhoto } from './types'
|
||||||
|
|
||||||
|
const tabStyle = (active: boolean, accentVar: string): React.CSSProperties => ({
|
||||||
|
padding: '10px 0', fontSize: 10, letterSpacing: '0.14em', borderBottom: '2px solid',
|
||||||
|
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||||
|
borderColor: active ? accentVar : 'transparent',
|
||||||
|
background: active ? 'var(--surface-1)' : 'transparent',
|
||||||
|
})
|
||||||
|
|
||||||
export default function FlightsPage() {
|
export default function FlightsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -36,6 +45,14 @@ export default function FlightsPage() {
|
|||||||
|
|
||||||
const [altDialog, setAltDialog] = useState<{ open: boolean; point: FlightPoint | null; isEdit: boolean }>({ open: false, point: null, isEdit: false })
|
const [altDialog, setAltDialog] = useState<{ open: boolean; point: FlightPoint | null; isEdit: boolean }>({ open: false, point: null, isEdit: false })
|
||||||
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
|
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
|
||||||
|
const [orthophotos, setOrthophotos] = useState<OrthoPhoto[]>([])
|
||||||
|
|
||||||
|
const handleApplyCorrection = useCallback((waypointNumber: number, lat: number, lon: number) => {
|
||||||
|
const idx = waypointNumber - 1
|
||||||
|
setPoints(prev => (idx < 0 || idx >= prev.length)
|
||||||
|
? prev
|
||||||
|
: prev.map((p, i) => i === idx ? { ...p, position: { lat, lng: lon } } : p))
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
@@ -47,6 +64,7 @@ export default function FlightsPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLiveGps(null) // drop the previous flight's GPS readout until the new stream sends a fix
|
||||||
if (!selectedFlight) { setPoints([]); return }
|
if (!selectedFlight) { setPoints([]); return }
|
||||||
api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id))
|
api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id))
|
||||||
.then(wps => {
|
.then(wps => {
|
||||||
@@ -128,28 +146,21 @@ export default function FlightsPage() {
|
|||||||
setAltDialog({ open: false, point: null, isEdit: false })
|
setAltDialog({ open: false, point: null, isEdit: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditJson = () => {
|
const buildFlightPlanData = () => ({
|
||||||
const data = {
|
|
||||||
operational_height: { currentAltitude: initialAltitude },
|
operational_height: { currentAltitude: initialAltitude },
|
||||||
geofences: { polygons: rectangles.map(r => {
|
geofences: { polygons: rectangles.map(r => {
|
||||||
const sw = r.bounds.getSouthWest(), ne = r.bounds.getNorthEast()
|
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' }
|
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 } })),
|
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 handleEditJson = () => {
|
||||||
|
setJsonDialog({ open: true, text: JSON.stringify(buildFlightPlanData(), null, 2) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
const data = {
|
const blob = new Blob([JSON.stringify(buildFlightPlanData(), null, 2)], { type: 'application/json' })
|
||||||
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 url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
@@ -242,29 +253,43 @@ export default function FlightsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<div className="w-10 bg-az-panel border-r border-az-border flex flex-col items-center py-2 gap-2 shrink-0">
|
<div className="shrink-0 flex flex-col items-center gap-2 border-r border-border-hair"
|
||||||
<button onClick={() => setCollapsed(false)} title="Expand"
|
style={{ width: 44, background: 'var(--surface-1)', padding: '10px 6px' }}>
|
||||||
className="w-8 h-8 rounded border border-az-border text-az-text hover:border-az-orange hover:text-az-orange text-sm">»</button>
|
<button onClick={() => setCollapsed(false)} title={t('flights.v2.expandParams')}
|
||||||
<button onClick={() => setActionMode('points')} title={t('flights.planner.addPoints')}
|
className="ibtn mono" style={{ width: 32, height: 32 }}>»</button>
|
||||||
className={`w-8 h-8 rounded border text-sm ${actionMode === 'points' ? 'border-az-orange text-az-orange bg-az-orange/20' : 'border-az-border text-az-text hover:border-az-orange'}`}>●</button>
|
<span className="block" style={{ width: 24, height: 1, background: 'var(--border-hair)' }} />
|
||||||
<button onClick={() => setActionMode('workArea')} title={t('flights.planner.workArea')}
|
{DRAW_MODES.map(({ mode: m, i18nKey, accent, icon }) => {
|
||||||
className={`w-8 h-8 rounded border text-az-green text-sm ${actionMode === 'workArea' ? 'border-az-green bg-az-green/20' : 'border-az-border hover:border-az-green'}`}>▣</button>
|
const active = actionMode === m
|
||||||
<button onClick={() => setActionMode('prohibitedArea')} title={t('flights.planner.prohibitedArea')}
|
const { color, tint } = DRAW_MODE_ACCENT[accent]
|
||||||
className={`w-8 h-8 rounded border text-az-red text-sm ${actionMode === 'prohibitedArea' ? 'border-az-red bg-az-red/20' : 'border-az-border hover:border-az-red'}`}>▣</button>
|
return (
|
||||||
|
<button key={m} onClick={() => setActionMode(m)} title={t(i18nKey)} className="mono"
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
border: `1px solid ${color}`, color, borderRadius: 2, cursor: 'pointer',
|
||||||
|
background: active ? tint : 'transparent',
|
||||||
|
boxShadow: active ? `inset 0 0 0 1px ${color}` : 'none',
|
||||||
|
}}>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-80 bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
<div className="shrink-0 flex flex-col overflow-y-auto border-r border-border-hair"
|
||||||
<div className="flex border-b border-az-border items-stretch">
|
style={{ width: 290, background: 'var(--surface-1)' }}>
|
||||||
|
<div className="flex items-stretch border-b border-border-hair" style={{ background: 'var(--surface-0)' }}>
|
||||||
<button onClick={() => setMode('params')}
|
<button onClick={() => setMode('params')}
|
||||||
className={`flex-1 py-1.5 text-[10px] ${mode === 'params' ? 'bg-az-bg text-white' : 'text-az-muted'}`}>
|
className="flex-1 mono uppercase"
|
||||||
{t('flights.params')}
|
style={tabStyle(mode === 'params', 'var(--accent-amber)')}>
|
||||||
|
{t('flights.v2.flightParams')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setMode('gps')}
|
<button onClick={() => setMode('gps')}
|
||||||
className={`flex-1 py-1.5 text-[10px] ${mode === 'gps' ? 'bg-az-bg text-white' : 'text-az-muted'}`}>
|
className="flex-1 mono uppercase"
|
||||||
{t('flights.gpsDenied')}
|
style={tabStyle(mode === 'gps', 'var(--accent-red)')}>
|
||||||
|
{t('flights.v2.gpsDenied')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setCollapsed(true)} title="Collapse"
|
<button onClick={() => setCollapsed(true)} title={t('flights.v2.collapse')}
|
||||||
className="px-2 text-az-muted hover:text-az-orange text-sm border-l border-az-border">«</button>
|
className="ibtn mono shrink-0 self-center mx-1" style={{ width: 26, height: 26 }}>«</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'params' && (
|
{mode === 'params' && (
|
||||||
@@ -282,24 +307,13 @@ export default function FlightsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'gps' && (
|
{mode === 'gps' && (
|
||||||
<div className="p-2 space-y-2 text-xs">
|
<GpsDeniedPanel
|
||||||
<div>
|
liveGps={liveGps}
|
||||||
<label className="text-az-muted block mb-1">{t('flights.liveGps')}</label>
|
orthophotos={orthophotos}
|
||||||
{liveGps ? (
|
onAddOrthophotos={(photos) => setOrthophotos(prev => [...prev, ...photos])}
|
||||||
<div className="bg-az-bg rounded p-1.5 space-y-0.5">
|
onApplyCorrection={handleApplyCorrection}
|
||||||
<div className="text-az-text">Status: <span className="text-az-green">{liveGps.status}</span></div>
|
onBack={() => setMode('params')}
|
||||||
<div className="text-az-text">Lat: {liveGps.lat.toFixed(6)}</div>
|
/>
|
||||||
<div className="text-az-text">Lon: {liveGps.lon.toFixed(6)}</div>
|
|
||||||
<div className="text-az-text">Sats: {liveGps.satellites}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-az-muted">Waiting for GPS signal...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setMode('params')} className="text-az-orange text-xs">
|
|
||||||
← {t('flights.back')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -315,6 +329,8 @@ export default function FlightsPage() {
|
|||||||
onPolylineClick={handlePolylineClick}
|
onPolylineClick={handlePolylineClick}
|
||||||
onPositionChange={setCurrentPosition}
|
onPositionChange={setCurrentPosition}
|
||||||
onMapMove={() => {}}
|
onMapMove={() => {}}
|
||||||
|
liveGps={liveGps}
|
||||||
|
flightLabel={selectedFlight?.name ?? '—'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AltitudeDialog
|
<AltitudeDialog
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { newGuid } from './flightPlanUtils'
|
||||||
|
import type { OrthoPhoto } from './types'
|
||||||
|
|
||||||
|
interface LiveGps {
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
satellites: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
liveGps: LiveGps | null
|
||||||
|
orthophotos: OrthoPhoto[]
|
||||||
|
onAddOrthophotos: (photos: OrthoPhoto[]) => void
|
||||||
|
/** Apply a manual GPS correction to a waypoint (1-based number as shown in the list). */
|
||||||
|
onApplyCorrection: (waypointNumber: number, lat: number, lon: number) => void
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPS-Denied operating mode. The orthophoto upload and correction form are
|
||||||
|
* functional-local (no backend endpoint exists yet); the Live GPS readout is
|
||||||
|
* fed by the real SSE stream via the `liveGps` prop.
|
||||||
|
*/
|
||||||
|
function Row({ label, className, children }: { label: string; className?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between py-1 ${className ?? ''}`}>
|
||||||
|
<span className="micro">{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GpsDeniedPanel({ liveGps, orthophotos, onAddOrthophotos, onApplyCorrection, onBack }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [wp, setWp] = useState('')
|
||||||
|
const [coords, setCoords] = useState('')
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = 'image/*'
|
||||||
|
input.multiple = true
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const files = Array.from((e.target as HTMLInputElement).files ?? [])
|
||||||
|
if (!files.length) return
|
||||||
|
const base = orthophotos.length
|
||||||
|
const photos: OrthoPhoto[] = files.map((f, i) => ({
|
||||||
|
id: newGuid(),
|
||||||
|
name: f.name,
|
||||||
|
lat: 48.8566 + (base + i) * 0.0046,
|
||||||
|
lon: 2.3522 + (base + i) * 0.0079,
|
||||||
|
}))
|
||||||
|
onAddOrthophotos(photos)
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
const num = parseInt(wp, 10)
|
||||||
|
const parts = coords.split(',').map(s => Number(s.trim()))
|
||||||
|
// Waypoint numbers are 1-based; reject 0/negative and non-numeric input.
|
||||||
|
if (!Number.isFinite(num) || num < 1 || parts.length !== 2 || !parts.every(Number.isFinite)) return
|
||||||
|
onApplyCorrection(num, parts[0], parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = liveGps?.status?.toUpperCase().includes('CONNECT') ?? false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="p-4 space-y-5 flex-1 overflow-y-auto">
|
||||||
|
<header className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="sect-head" style={{ color: 'var(--accent-red)', whiteSpace: 'nowrap' }}>{t('flights.v2.gpsDeniedActive')}</h2>
|
||||||
|
<span className="pill pill-red" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
<span className="dot live" />{t('flights.v2.active')}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Orthophoto upload — red frame (mockup: .bracket-red + .gps-active-frame).
|
||||||
|
Remap --accent-amber→red locally so the .bracket corner ticks render red;
|
||||||
|
no amber-colored children live inside this frame. */}
|
||||||
|
<div className="bracket panel" style={{
|
||||||
|
padding: 12,
|
||||||
|
border: '2px solid var(--accent-red)',
|
||||||
|
boxShadow: 'inset 0 0 0 1px rgba(255,71,86,0.12)',
|
||||||
|
['--accent-amber' as string]: 'var(--accent-red)',
|
||||||
|
} as React.CSSProperties}>
|
||||||
|
<header className="flex items-center justify-between" style={{ marginBottom: 12 }}>
|
||||||
|
<span className="sect-head" style={{ color: 'var(--accent-red)' }}>// {t('flights.v2.orthophotoUpload')}</span>
|
||||||
|
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{String(orthophotos.length).padStart(2, '0')} / 12
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{orthophotos.map((p, i) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-2.5 border border-border-hair"
|
||||||
|
style={{ padding: '8px 10px', background: 'var(--surface-0)' }}>
|
||||||
|
<span className="flex items-center justify-center shrink-0 mono"
|
||||||
|
style={{ width: 24, height: 24, background: 'var(--accent-cyan)', color: '#0A0D10', fontSize: 10, fontWeight: 700 }}>
|
||||||
|
P{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[11px] flex-1 truncate" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
||||||
|
<span className="mono text-[10px]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{p.lat.toFixed(4)}, {p.lon.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={handleUpload}
|
||||||
|
className="w-full mono flex items-center justify-center gap-2"
|
||||||
|
style={{ marginTop: 10, padding: '8px 0', fontSize: 10, letterSpacing: '0.12em', textTransform: 'uppercase',
|
||||||
|
border: '1px dashed var(--border-raised)', color: 'var(--text-secondary)', background: 'transparent', borderRadius: 2 }}>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M5 1 V9 M1 5 H9" stroke="currentColor" strokeWidth="1.4" /></svg>
|
||||||
|
{t('flights.v2.uploadPhotos')}
|
||||||
|
</button>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live GPS readout */}
|
||||||
|
<div className="bracket panel" style={{ padding: 12 }}>
|
||||||
|
<header className="flex items-center justify-between gap-2" style={{ marginBottom: 10 }}>
|
||||||
|
<span className="sect-head" style={{ whiteSpace: 'nowrap' }}>// {t('flights.v2.liveGps')}</span>
|
||||||
|
<span className={`pill ${connected ? 'pill-green' : 'pill-muted'}`} style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
<span className="dot live" />{connected ? t('flights.v2.connected') : t('flights.v2.offline')}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="space-y-1.5 text-[12px]">
|
||||||
|
<Row label={t('flights.v2.status')} className="border-b border-border-hair">
|
||||||
|
<span className="mono" style={{ whiteSpace: 'nowrap', color: connected ? 'var(--accent-green)' : 'var(--text-secondary)' }}>
|
||||||
|
{connected ? t('flights.v2.connectedStreaming') : t('flights.v2.offline')}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('flights.v2.latitude')} className="border-b border-border-hair">
|
||||||
|
<span className="mono num">{(liveGps?.lat ?? 0).toFixed(5)}° N</span>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('flights.v2.longitude')} className="border-b border-border-hair">
|
||||||
|
<span className="mono num">{(liveGps?.lon ?? 0).toFixed(5)}° E</span>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('flights.v2.satellites')} className="border-b border-border-hair">
|
||||||
|
<span className="mono num" style={{ color: 'var(--accent-cyan)' }}>{liveGps?.satellites ?? 0} / 14</span>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('flights.v2.drift')}>
|
||||||
|
<span className="mono num" style={{ color: 'var(--accent-amber)' }}>±2.4 M</span>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GPS Correction */}
|
||||||
|
<div className="bracket panel" style={{ padding: 12 }}>
|
||||||
|
<header className="flex items-center justify-between" style={{ marginBottom: 10 }}>
|
||||||
|
<span className="sect-head">// {t('flights.v2.gpsCorrection')}</span>
|
||||||
|
</header>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.v2.waypointNum')}</label>
|
||||||
|
<input value={wp} onChange={e => setWp(e.target.value)} type="number" className="inp inp-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.v2.correctedGps')}</label>
|
||||||
|
<input value={coords} onChange={e => setCoords(e.target.value)} type="text" placeholder="48.86120, 2.36011" className="inp inp-mono" />
|
||||||
|
</div>
|
||||||
|
<button onClick={handleApply} className="btn btn-primary w-full justify-center">{t('flights.v2.applyCorrection')}</button>
|
||||||
|
</div>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={onBack} className="btn btn-ghost w-full justify-center">‹ {t('flights.v2.backToParams')}</button>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,30 +23,36 @@ export default function JsonEditorDialog({ open, jsonText, onClose, onSave }: Pr
|
|||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[2000]">
|
<div className="fixed inset-0 flex items-center justify-center z-[2000]" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
||||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-[700px] max-h-[80vh] shadow-xl flex flex-col">
|
<div
|
||||||
<h3 className="text-white font-semibold mb-2">{t('flights.planner.editAsJson')}</h3>
|
className="bracket panel shadow-xl flex flex-col"
|
||||||
|
style={{ background: 'var(--surface-1)', padding: '20px', width: '700px', maxHeight: '80vh' }}
|
||||||
|
>
|
||||||
|
<h3 className="sect-head mb-3">{t('flights.planner.editAsJson')}</h3>
|
||||||
<textarea
|
<textarea
|
||||||
value={edited}
|
value={edited}
|
||||||
onChange={e => handleChange(e.target.value)}
|
onChange={e => handleChange(e.target.value)}
|
||||||
rows={20}
|
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 ${
|
className="inp inp-mono flex-1 resize-none"
|
||||||
valid ? 'border-az-border focus:border-az-orange' : 'border-az-red'
|
style={{
|
||||||
}`}
|
maxHeight: '60vh',
|
||||||
|
borderColor: valid ? undefined : 'var(--accent-red)',
|
||||||
|
boxShadow: valid ? undefined : '0 0 0 1px var(--accent-red)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p className={`text-xs mt-1 ${valid ? 'text-az-muted' : 'text-az-red'}`}>
|
<p className="text-[11px] mt-1.5" style={{ color: valid ? 'var(--text-secondary)' : 'var(--accent-red)' }}>
|
||||||
{valid ? t('flights.planner.editJsonHint') : t('flights.planner.invalidJson')}
|
{valid ? t('flights.planner.editJsonHint') : t('flights.planner.invalidJson')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-2 mt-3">
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
<button onClick={onClose}
|
<button onClick={onClose} className="btn btn-ghost">
|
||||||
className="px-3 py-1 text-sm border border-az-border rounded hover:bg-az-bg text-az-text">
|
|
||||||
{t('flights.planner.cancel')}
|
{t('flights.planner.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => valid && onSave(edited)} disabled={!valid}
|
<button onClick={() => valid && onSave(edited)} disabled={!valid} className="btn btn-primary">
|
||||||
className="px-3 py-1 text-sm bg-az-orange rounded hover:bg-orange-600 text-white disabled:opacity-40">
|
|
||||||
{t('flights.planner.save')}
|
{t('flights.planner.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span className="br" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { Marker, Popup } from 'react-leaflet'
|
import { Marker, Popup } from 'react-leaflet'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { pointIconGreen, pointIconBlue, pointIconRed } from './mapIcons'
|
import { wpStartIcon, wpMidIcon, wpFinishIcon } from './mapIcons'
|
||||||
import { PURPOSES } from './types'
|
import { PURPOSES } from './types'
|
||||||
import type { FlightPoint, MovingPointInfo } from './types'
|
import type { FlightPoint, MovingPointInfo } from './types'
|
||||||
import type L from 'leaflet'
|
import type L from 'leaflet'
|
||||||
@@ -26,7 +26,7 @@ export default function MapPoint({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const markerRef = useRef<L.Marker>(null)
|
const markerRef = useRef<L.Marker>(null)
|
||||||
|
|
||||||
const icon = index === 0 ? pointIconGreen : index === points.length - 1 ? pointIconRed : pointIconBlue
|
const icon = index === 0 ? wpStartIcon : index === points.length - 1 ? wpFinishIcon : wpMidIcon
|
||||||
|
|
||||||
const handleMove = (e: L.LeafletEvent) => {
|
const handleMove = (e: L.LeafletEvent) => {
|
||||||
const marker = markerRef.current
|
const marker = markerRef.current
|
||||||
@@ -58,26 +58,55 @@ export default function MapPoint({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="text-xs space-y-1.5 min-w-[140px]">
|
<div style={{ minWidth: 148, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<div className="font-semibold">{t('flights.planner.point')} {index + 1}</div>
|
<div
|
||||||
<div>
|
className="mono"
|
||||||
<label className="text-az-muted text-[10px]">{t('flights.planner.altitude')}</label>
|
style={{ color: 'var(--accent-amber)', fontSize: 12, fontWeight: 600 }}
|
||||||
<input type="range" min={0} max={3000} value={point.altitude}
|
>
|
||||||
onChange={e => onAltitudeChange(index, Number(e.target.value))}
|
{t('flights.planner.point')} {index + 1}
|
||||||
className="w-full accent-az-orange" />
|
|
||||||
<span className="text-[10px] text-az-muted">{point.altitude}m</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<label style={{ color: 'var(--text-secondary)', fontSize: 11 }}>
|
||||||
|
{t('flights.planner.altitude')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={3000}
|
||||||
|
value={point.altitude}
|
||||||
|
onChange={e => onAltitudeChange(index, Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
style={{ accentColor: 'var(--accent-amber)' }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="mono"
|
||||||
|
style={{ color: 'var(--text-primary)', fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{point.altitude}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
{PURPOSES.map(p => (
|
{PURPOSES.map(p => (
|
||||||
<label key={p.value} className="flex items-center gap-1 text-[10px] cursor-pointer">
|
<label
|
||||||
<input type="checkbox" checked={point.meta.includes(p.value)}
|
key={p.value}
|
||||||
onChange={() => toggleMeta(p.value)} className="accent-az-orange" />
|
style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', color: 'var(--text-primary)', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={point.meta.includes(p.value)}
|
||||||
|
onChange={() => toggleMeta(p.value)}
|
||||||
|
style={{ accentColor: 'var(--accent-amber)' }}
|
||||||
|
/>
|
||||||
{t(`flights.planner.${p.label}`)}
|
{t(`flights.planner.${p.label}`)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => onRemove(point.id)}
|
<button
|
||||||
className="text-az-red text-[10px] hover:underline">
|
onClick={() => onRemove(point.id)}
|
||||||
|
style={{ color: 'var(--accent-red)', fontSize: 11, background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left', textDecoration: 'none' }}
|
||||||
|
onMouseOver={e => (e.currentTarget.style.textDecoration = 'underline')}
|
||||||
|
onMouseOut={e => (e.currentTarget.style.textDecoration = 'none')}
|
||||||
|
>
|
||||||
{t('flights.planner.removePoint')}
|
{t('flights.planner.removePoint')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ interface Props {
|
|||||||
export default function MiniMap({ pointPosition }: Props) {
|
export default function MiniMap({ pointPosition }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute w-[240px] h-[180px] border border-az-border rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
|
className="absolute w-[240px] h-[180px] border border-border-hair rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
|
||||||
style={{ top: pointPosition.y, left: pointPosition.x }}
|
style={{ top: pointPosition.y, left: pointPosition.x }}
|
||||||
>
|
>
|
||||||
<MapContainer center={pointPosition.latlng} zoom={18} zoomControl={false}
|
<MapContainer center={pointPosition.latlng} zoom={18} zoomControl={false}
|
||||||
className="w-full h-full" attributionControl={false}>
|
className="w-full h-full" attributionControl={false}>
|
||||||
<TileLayer url={getTileUrl()} crossOrigin="use-credentials" />
|
<TileLayer url={getTileUrl()} crossOrigin="use-credentials" />
|
||||||
<CircleMarker center={pointPosition.latlng} radius={3} color="#fa5252" />
|
<CircleMarker center={pointPosition.latlng} radius={3} color="#FF4756" />
|
||||||
<UpdateCenter latlng={pointPosition.latlng} />
|
<UpdateCenter latlng={pointPosition.latlng} />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,25 +29,94 @@ export default function WaypointList({ points, calculatedPointInfo, onReorder, o
|
|||||||
return `${alt}${t('flights.planner.metres')} ${Math.floor(info.bat)}%${t('flights.planner.battery')} ${timeStr}`
|
return `${alt}${t('flights.planner.metres')} ${Math.floor(info.bat)}%${t('flights.planner.battery')} ${timeStr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderMarker = (index: number) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: 'var(--accent-green)',
|
||||||
|
transform: 'rotate(45deg)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (index === points.length - 1 && points.length > 1) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: 'var(--accent-red)',
|
||||||
|
clipPath: 'polygon(30% 0,70% 0,100% 30%,100% 70%,70% 100%,30% 100%,0 70%,0 30%)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1.5px solid var(--accent-cyan)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={handleDragEnd}>
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
<Droppable droppableId="waypoints">
|
<Droppable droppableId="waypoints">
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-0.5">
|
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-0">
|
||||||
{points.map((point, index) => (
|
{points.map((point, index) => (
|
||||||
<Draggable key={point.id} draggableId={point.id} index={index}>
|
<Draggable key={point.id} draggableId={point.id} index={index}>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}
|
<div
|
||||||
className="flex items-center justify-between bg-az-bg rounded px-1.5 py-1 text-[10px] text-az-text group">
|
ref={provided.innerRef}
|
||||||
<span>
|
{...provided.draggableProps}
|
||||||
<span className="text-az-orange font-bold mr-1">
|
{...provided.dragHandleProps}
|
||||||
|
className="flex items-center gap-2.5 border-b border-border-hair mono group"
|
||||||
|
style={{ height: 30, padding: '0 4px', ...provided.draggableProps.style }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mono text-[11px]"
|
||||||
|
style={{ color: 'var(--text-secondary)', width: 28, flexShrink: 0 }}
|
||||||
|
>
|
||||||
{String(index + 1).padStart(2, '0')}
|
{String(index + 1).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{renderMarker(index)}
|
||||||
|
|
||||||
|
<span className="text-[11px] text-text-primary truncate flex-1">
|
||||||
{formatInfo(calculatedPointInfo[index], point.altitude)}
|
{formatInfo(calculatedPointInfo[index], point.altitude)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex gap-1 opacity-0 group-hover:opacity-100">
|
|
||||||
<button onClick={() => onEdit(point)} className="hover:text-az-orange">✎</button>
|
<span className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100">
|
||||||
<button onClick={() => onRemove(point.id)} className="hover:text-az-red">×</button>
|
<button
|
||||||
|
onClick={() => onEdit(point)}
|
||||||
|
className="ibtn edit"
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
title={t('flights.planner.edit')}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(point.id)}
|
||||||
|
className="ibtn danger"
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
title={t('flights.planner.remove')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,19 +12,19 @@ export default function WindEffect({ wind, onChange }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.windDirection')}</label>
|
<label className="micro block mb-0.5">{t('flights.planner.windDirection')}</label>
|
||||||
<input type="number" min={0} max={360}
|
<input type="number" min={0} max={360}
|
||||||
value={wind.direction}
|
value={wind.direction}
|
||||||
onChange={e => onChange({ ...wind, direction: Number(e.target.value) })}
|
onChange={e => onChange({ ...wind, direction: Number(e.target.value) })}
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none focus:border-az-orange"
|
className="inp inp-mono w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.windSpeed')}</label>
|
<label className="micro block mb-0.5">{t('flights.planner.windSpeed')}</label>
|
||||||
<input type="number" min={0}
|
<input type="number" min={0}
|
||||||
value={wind.speed}
|
value={wind.speed}
|
||||||
onChange={e => onChange({ ...wind, speed: Number(e.target.value) })}
|
onChange={e => onChange({ ...wind, speed: Number(e.target.value) })}
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none focus:border-az-orange"
|
className="inp inp-mono w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
|||||||
vi.mock('leaflet-polylinedecorator', () => ({}))
|
vi.mock('leaflet-polylinedecorator', () => ({}))
|
||||||
vi.mock('../DrawControl', () => ({ default: () => null }))
|
vi.mock('../DrawControl', () => ({ default: () => null }))
|
||||||
vi.mock('../MapPoint', () => ({ default: () => null }))
|
vi.mock('../MapPoint', () => ({ default: () => null }))
|
||||||
vi.mock('../mapIcons', () => ({ defaultIcon: {} }))
|
vi.mock('../mapIcons', () => ({ currentPositionIcon: {} }))
|
||||||
|
|
||||||
import FlightMap from '../FlightMap'
|
import FlightMap from '../FlightMap'
|
||||||
import MiniMap from '../MiniMap'
|
import MiniMap from '../MiniMap'
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ActionMode } from './types'
|
||||||
|
|
||||||
|
export type DrawAccent = 'amber' | 'green' | 'red'
|
||||||
|
|
||||||
|
/** Accent color + active-state tint per draw mode. Shared by the collapsed rail
|
||||||
|
* (FlightsPage) and the expanded draw-mode selector (FlightParamsPanel). */
|
||||||
|
export const DRAW_MODE_ACCENT: Record<DrawAccent, { color: string; tint: string }> = {
|
||||||
|
amber: { color: 'var(--accent-amber)', tint: 'rgba(255,157,61,0.20)' },
|
||||||
|
green: { color: 'var(--accent-green)', tint: 'rgba(61,220,132,0.18)' },
|
||||||
|
red: { color: 'var(--accent-red)', tint: 'rgba(255,71,86,0.18)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single source of truth for the three flight-plan draw modes: the action mode,
|
||||||
|
* its i18n label key, accent, and icon. Consumed by both the icon-only collapsed
|
||||||
|
* rail and the labelled expanded selector. */
|
||||||
|
export const DRAW_MODES: { mode: ActionMode; i18nKey: string; accent: DrawAccent; icon: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
mode: 'points', i18nKey: 'flights.v2.points', accent: 'amber',
|
||||||
|
icon: <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="1.6" fill="currentColor" /><circle cx="18" cy="6" r="1.6" fill="currentColor" /><circle cx="12" cy="14" r="1.6" fill="currentColor" /><circle cx="6" cy="20" r="1.6" fill="currentColor" /><circle cx="18" cy="20" r="1.6" fill="currentColor" /></svg>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'workArea', i18nKey: 'flights.v2.workArea', accent: 'green',
|
||||||
|
icon: <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="4 7 12 3 20 7 20 17 12 21 4 17" /></svg>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'prohibitedArea', i18nKey: 'flights.v2.noGoZone', accent: 'red',
|
||||||
|
icon: <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><line x1="5.6" y1="5.6" x2="18.4" y2="18.4" /></svg>,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,23 +1,45 @@
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import markerIcon from 'leaflet/dist/images/marker-icon.png'
|
|
||||||
|
|
||||||
function pinIcon(color: string) {
|
// v2 waypoint glyphs — match the map legend shapes exactly:
|
||||||
|
// start → green diamond (.wp-diamond)
|
||||||
|
// middle → cyan-bordered square (.wp-square)
|
||||||
|
// finish → red octagon (.wp-octagon)
|
||||||
|
function glyphIcon(html: string, size: number) {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
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>`,
|
html: `<div style="display:flex;align-items:center;justify-content:center;width:${size}px;height:${size}px;">${html}</div>`,
|
||||||
iconSize: [24, 24],
|
iconSize: [size, size],
|
||||||
iconAnchor: [12, 24],
|
iconAnchor: [size / 2, size / 2],
|
||||||
popupAnchor: [0, -24],
|
popupAnchor: [0, -(size / 2) - 2],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pointIconGreen = pinIcon('#1ed013')
|
export const wpStartIcon = glyphIcon(
|
||||||
export const pointIconBlue = pinIcon('#228be6')
|
`<div style="width:14px;height:14px;background:#3DDC84;border:1.5px solid #0A0D10;box-shadow:0 0 0 1px #3DDC84;transform:rotate(45deg);"></div>`,
|
||||||
export const pointIconRed = pinIcon('#fa5252')
|
20,
|
||||||
|
)
|
||||||
|
export const wpMidIcon = glyphIcon(
|
||||||
|
`<div style="width:12px;height:12px;background:#0A0D10;border:1.5px solid #36D6C5;"></div>`,
|
||||||
|
16,
|
||||||
|
)
|
||||||
|
export const wpFinishIcon = glyphIcon(
|
||||||
|
`<div style="width:16px;height:16px;background:#FF4756;clip-path:polygon(30% 0,70% 0,100% 30%,100% 70%,70% 100%,30% 100%,0 70%,0 30%);"></div>`,
|
||||||
|
18,
|
||||||
|
)
|
||||||
|
|
||||||
export const defaultIcon = new L.Icon({
|
// v2 current-position beacon: amber center dot with an expanding pulse ring.
|
||||||
iconUrl: markerIcon,
|
// Self-contained SVG/SMIL animation so it needs no global CSS keyframes.
|
||||||
iconSize: [25, 41],
|
export const currentPositionIcon = L.divIcon({
|
||||||
iconAnchor: [12, 41],
|
className: '',
|
||||||
popupAnchor: [1, -34],
|
html: `<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 34 34">
|
||||||
|
<circle cx="17" cy="17" r="5" fill="none" stroke="#FF9D3D" stroke-width="1.5">
|
||||||
|
<animate attributeName="r" values="5;15" dur="1.6s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="opacity" values="0.7;0" dur="1.6s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="17" cy="17" r="8" fill="none" stroke="#FF9D3D" stroke-width="1" opacity="0.45"/>
|
||||||
|
<circle cx="17" cy="17" r="4" fill="#FF9D3D" stroke="#0A0D10" stroke-width="1"/>
|
||||||
|
</svg>`,
|
||||||
|
iconSize: [34, 34],
|
||||||
|
iconAnchor: [17, 17],
|
||||||
|
popupAnchor: [0, -17],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ export interface WindParams {
|
|||||||
speed: number
|
speed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Local-only orthophoto entry for the GPS-Denied upload list. There is no
|
||||||
|
// backend endpoint for orthophoto upload yet, so this lives entirely in
|
||||||
|
// component state (see GpsDeniedPanel / FlightsPage).
|
||||||
|
export interface OrthoPhoto {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MovingPointInfo {
|
export interface MovingPointInfo {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
|||||||
+128
-4
@@ -34,6 +34,81 @@
|
|||||||
"correction": "GPS Correction",
|
"correction": "GPS Correction",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"telemetry": "Telemetry",
|
"telemetry": "Telemetry",
|
||||||
|
"v2": {
|
||||||
|
"roster": "Flight Roster",
|
||||||
|
"search": "Search flights",
|
||||||
|
"draft": "Draft",
|
||||||
|
"createNew": "Create New",
|
||||||
|
"missionConfig": "Mission Config",
|
||||||
|
"drawMode": "Draw Mode",
|
||||||
|
"clickToPlot": "click map to plot",
|
||||||
|
"points": "Points",
|
||||||
|
"workArea": "Work Area",
|
||||||
|
"noGoZone": "No-Go Zone",
|
||||||
|
"aircraft": "Aircraft",
|
||||||
|
"defaultHeight": "Default Height",
|
||||||
|
"focalLength": "Focal Length",
|
||||||
|
"commAddr": "Comm Address / Port",
|
||||||
|
"pts": "PTS",
|
||||||
|
"wpStart": "START",
|
||||||
|
"wpFinish": "FINISH",
|
||||||
|
"tagOrigin": "ORIGIN",
|
||||||
|
"tagTrack": "TRACK",
|
||||||
|
"tagConfirm": "CONFIRM",
|
||||||
|
"tagTarget": "TARGET",
|
||||||
|
"tagMilVeh": "MIL-VEH",
|
||||||
|
"flightParams": "Flight Params",
|
||||||
|
"gpsDenied": "GPS-Denied",
|
||||||
|
"gpsDeniedActive": "GPS-Denied // Active",
|
||||||
|
"orthophotoUpload": "Orthophoto Upload",
|
||||||
|
"uploadPhotos": "Upload Photos",
|
||||||
|
"liveGps": "Live GPS",
|
||||||
|
"connected": "CONNECTED",
|
||||||
|
"connectedStreaming": "CONNECTED · STREAMING",
|
||||||
|
"active": "Active",
|
||||||
|
"offline": "Offline",
|
||||||
|
"status": "Status",
|
||||||
|
"latitude": "Latitude",
|
||||||
|
"longitude": "Longitude",
|
||||||
|
"satellites": "Satellites",
|
||||||
|
"drift": "Drift",
|
||||||
|
"gpsCorrection": "GPS Correction",
|
||||||
|
"waypointNum": "Waypoint #",
|
||||||
|
"correctedGps": "Corrected GPS",
|
||||||
|
"applyCorrection": "Apply Correction",
|
||||||
|
"backToParams": "Back to Flight Params",
|
||||||
|
"upload": "Upload",
|
||||||
|
"expandParams": "Expand parameters",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"date": "Date",
|
||||||
|
"drawHintWork": "Click and drag on the map to draw a work area",
|
||||||
|
"drawHintNoGo": "Click and drag on the map to draw a no-go zone",
|
||||||
|
"hud": {
|
||||||
|
"liveConnected": "LIVE · CONNECTED",
|
||||||
|
"sat": "Sat",
|
||||||
|
"lat": "Lat",
|
||||||
|
"lon": "Lon",
|
||||||
|
"alt": "Alt",
|
||||||
|
"hdg": "Hdg",
|
||||||
|
"spd": "Spd",
|
||||||
|
"link": "Link",
|
||||||
|
"mapLegend": "Map Legend",
|
||||||
|
"plannedOriginal": "Planned · Original",
|
||||||
|
"correctedLive": "Corrected · Live",
|
||||||
|
"originStart": "Origin / Start",
|
||||||
|
"waypoint": "Waypoint",
|
||||||
|
"targetFinish": "Target / Finish",
|
||||||
|
"zoomIn": "Zoom in",
|
||||||
|
"zoomOut": "Zoom out",
|
||||||
|
"recenter": "Recenter",
|
||||||
|
"layers": "Layers"
|
||||||
|
},
|
||||||
|
"strip": {
|
||||||
|
"telemetryLive": "TELEMETRY · LIVE",
|
||||||
|
"frame": "FRAME",
|
||||||
|
"lastPing": "LAST PING"
|
||||||
|
}
|
||||||
|
},
|
||||||
"planner": {
|
"planner": {
|
||||||
"point": "Point",
|
"point": "Point",
|
||||||
"altitude": "Altitude",
|
"altitude": "Altitude",
|
||||||
@@ -58,6 +133,8 @@
|
|||||||
"submitAdd": "Add Point",
|
"submitAdd": "Add Point",
|
||||||
"submitEdit": "Save Changes",
|
"submitEdit": "Save Changes",
|
||||||
"removePoint": "Delete",
|
"removePoint": "Delete",
|
||||||
|
"edit": "Edit point",
|
||||||
|
"remove": "Remove point",
|
||||||
"windSpeed": "Wind spd",
|
"windSpeed": "Wind spd",
|
||||||
"windDirection": "Wind dir",
|
"windDirection": "Wind dir",
|
||||||
"setWind": "Set Wind",
|
"setWind": "Set Wind",
|
||||||
@@ -85,18 +162,47 @@
|
|||||||
},
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Annotations",
|
"title": "Annotations",
|
||||||
"mediaList": "Media",
|
"mediaList": "Media Files",
|
||||||
|
"filterByName": "filter by name…",
|
||||||
"upload": "Upload Files",
|
"upload": "Upload Files",
|
||||||
"deleteMedia": "Delete media?",
|
"deleteMedia": "Delete media?",
|
||||||
"detect": "AI Detect",
|
"detect": "AI Detect",
|
||||||
|
"detectInProgress": "AI DETECTION IN PROGRESS",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteAll": "Delete All",
|
"deleteAll": "Delete All",
|
||||||
|
"deleteAllTitle": "Delete all on frame",
|
||||||
"classes": "Detection Classes",
|
"classes": "Detection Classes",
|
||||||
"photoMode": "PhotoMode",
|
"photoMode": "PhotoMode",
|
||||||
"regular": "Regular",
|
"regular": "Regular",
|
||||||
"winter": "Winter",
|
"winter": "Winter",
|
||||||
"night": "Night"
|
"night": "Night",
|
||||||
|
"colName": "NAME",
|
||||||
|
"colKey": "KEY",
|
||||||
|
"colNum": "#",
|
||||||
|
"colTime": "TIME",
|
||||||
|
"colClass": "CLASS",
|
||||||
|
"colConf": "CONF",
|
||||||
|
"canvas": "Canvas",
|
||||||
|
"zoom": "ZOOM",
|
||||||
|
"cursor": "CURSOR",
|
||||||
|
"frameStep": "FRAME STEP",
|
||||||
|
"frame": "FRAME",
|
||||||
|
"summary": "SUMMARY",
|
||||||
|
"emptyFrame": "empty frame",
|
||||||
|
"filter": "Filter",
|
||||||
|
"sort": "Sort",
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"previousMedia": "Previous media",
|
||||||
|
"nextMedia": "Next media",
|
||||||
|
"back5s": "Back 5s",
|
||||||
|
"forward5s": "Forward 5s",
|
||||||
|
"mute": "Mute",
|
||||||
|
"selectMedia": "Select a media file to start",
|
||||||
|
"annCount_one": "{{count}} ann",
|
||||||
|
"annCount_other": "{{count}} ann",
|
||||||
|
"emptyCount": "{{count}} empty"
|
||||||
},
|
},
|
||||||
"dataset": {
|
"dataset": {
|
||||||
"title": "Dataset Explorer",
|
"title": "Dataset Explorer",
|
||||||
@@ -104,12 +210,30 @@
|
|||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
"classDistribution": "Class Distribution",
|
"classDistribution": "Class Distribution",
|
||||||
"objectsOnly": "Show with objects only",
|
"objectsOnly": "Show with objects only",
|
||||||
"search": "Search...",
|
"hideEmpty": "Hide empty frames",
|
||||||
|
"search": "Search annotation name…",
|
||||||
"validate": "Validate",
|
"validate": "Validate",
|
||||||
|
"edit": "Edit",
|
||||||
|
"filters": "Filters",
|
||||||
|
"total": "Total",
|
||||||
|
"validatedCount": "Validated",
|
||||||
|
"range": "Range",
|
||||||
|
"flight": "Flight",
|
||||||
|
"showing": "Showing",
|
||||||
|
"liveSync": "Live sync",
|
||||||
|
"selected": "Selected",
|
||||||
|
"refreshThumbnails": "Refresh Thumbnails",
|
||||||
|
"ofSelected": "{{count}} of {{total}} selected",
|
||||||
|
"local": "Local",
|
||||||
|
"sort": "Sort",
|
||||||
|
"gridDensity": "Grid density",
|
||||||
|
"statusLabel": "Status",
|
||||||
"status": {
|
"status": {
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"edited": "Edited",
|
"edited": "Edited",
|
||||||
"validated": "Validated"
|
"validated": "Validated",
|
||||||
|
"all": "All",
|
||||||
|
"none": "None"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
+130
-4
@@ -34,6 +34,81 @@
|
|||||||
"correction": "Корекція GPS",
|
"correction": "Корекція GPS",
|
||||||
"apply": "Застосувати",
|
"apply": "Застосувати",
|
||||||
"telemetry": "Телеметрія",
|
"telemetry": "Телеметрія",
|
||||||
|
"v2": {
|
||||||
|
"roster": "Реєстр польотів",
|
||||||
|
"search": "Пошук польотів",
|
||||||
|
"draft": "Чернетка",
|
||||||
|
"createNew": "Створити новий",
|
||||||
|
"missionConfig": "Конфігурація місії",
|
||||||
|
"drawMode": "Режим малювання",
|
||||||
|
"clickToPlot": "клікніть на карту",
|
||||||
|
"points": "Точки",
|
||||||
|
"workArea": "Робоча зона",
|
||||||
|
"noGoZone": "Заборонена зона",
|
||||||
|
"aircraft": "Літальний апарат",
|
||||||
|
"defaultHeight": "Висота за замовч.",
|
||||||
|
"focalLength": "Фокусна відстань",
|
||||||
|
"commAddr": "Адреса / Порт зв'язку",
|
||||||
|
"pts": "ТЧК",
|
||||||
|
"wpStart": "СТАРТ",
|
||||||
|
"wpFinish": "ФІНІШ",
|
||||||
|
"tagOrigin": "ПОЧАТОК",
|
||||||
|
"tagTrack": "ТРЕК",
|
||||||
|
"tagConfirm": "ПІДТВ.",
|
||||||
|
"tagTarget": "ЦІЛЬ",
|
||||||
|
"tagMilVeh": "ВІЙСЬК-ТЕХ",
|
||||||
|
"flightParams": "Параметри польоту",
|
||||||
|
"gpsDenied": "GPS-Denied",
|
||||||
|
"gpsDeniedActive": "GPS-Denied // Активно",
|
||||||
|
"orthophotoUpload": "Завантаження ортофото",
|
||||||
|
"uploadPhotos": "Завантажити фото",
|
||||||
|
"liveGps": "GPS Потік",
|
||||||
|
"connected": "З'ЄДНАНО",
|
||||||
|
"connectedStreaming": "З'ЄДНАНО · ПОТІК",
|
||||||
|
"active": "Активно",
|
||||||
|
"offline": "Офлайн",
|
||||||
|
"status": "Статус",
|
||||||
|
"latitude": "Широта",
|
||||||
|
"longitude": "Довгота",
|
||||||
|
"satellites": "Супутники",
|
||||||
|
"drift": "Відхилення",
|
||||||
|
"gpsCorrection": "Корекція GPS",
|
||||||
|
"waypointNum": "Точка №",
|
||||||
|
"correctedGps": "Скориговані GPS",
|
||||||
|
"applyCorrection": "Застосувати корекцію",
|
||||||
|
"backToParams": "Назад до параметрів",
|
||||||
|
"upload": "Завантажити",
|
||||||
|
"expandParams": "Розгорнути параметри",
|
||||||
|
"collapse": "Згорнути",
|
||||||
|
"date": "Дата",
|
||||||
|
"drawHintWork": "Клікніть і потягніть на карті, щоб намалювати робочу зону",
|
||||||
|
"drawHintNoGo": "Клікніть і потягніть на карті, щоб намалювати заборонену зону",
|
||||||
|
"hud": {
|
||||||
|
"liveConnected": "ЕФІР · З'ЄДНАНО",
|
||||||
|
"sat": "Супут",
|
||||||
|
"lat": "Шир",
|
||||||
|
"lon": "Довг",
|
||||||
|
"alt": "Вис",
|
||||||
|
"hdg": "Курс",
|
||||||
|
"spd": "Швид",
|
||||||
|
"link": "Зв'язок",
|
||||||
|
"mapLegend": "Легенда карти",
|
||||||
|
"plannedOriginal": "Планований · Оригінал",
|
||||||
|
"correctedLive": "Скоригований · Ефір",
|
||||||
|
"originStart": "Початок / Старт",
|
||||||
|
"waypoint": "Точка маршруту",
|
||||||
|
"targetFinish": "Ціль / Фініш",
|
||||||
|
"zoomIn": "Збільшити",
|
||||||
|
"zoomOut": "Зменшити",
|
||||||
|
"recenter": "Центрувати",
|
||||||
|
"layers": "Шари"
|
||||||
|
},
|
||||||
|
"strip": {
|
||||||
|
"telemetryLive": "ТЕЛЕМЕТРІЯ · ЕФІР",
|
||||||
|
"frame": "КАДР",
|
||||||
|
"lastPing": "ОСТ. ПІНГ"
|
||||||
|
}
|
||||||
|
},
|
||||||
"planner": {
|
"planner": {
|
||||||
"point": "Точка",
|
"point": "Точка",
|
||||||
"altitude": "Висота",
|
"altitude": "Висота",
|
||||||
@@ -58,6 +133,8 @@
|
|||||||
"submitAdd": "Додати точку",
|
"submitAdd": "Додати точку",
|
||||||
"submitEdit": "Зберегти зміни",
|
"submitEdit": "Зберегти зміни",
|
||||||
"removePoint": "Видалити",
|
"removePoint": "Видалити",
|
||||||
|
"edit": "Редагувати точку",
|
||||||
|
"remove": "Видалити точку",
|
||||||
"windSpeed": "Шв. вітру",
|
"windSpeed": "Шв. вітру",
|
||||||
"windDirection": "Напр. вітру",
|
"windDirection": "Напр. вітру",
|
||||||
"setWind": "Вітер",
|
"setWind": "Вітер",
|
||||||
@@ -85,18 +162,49 @@
|
|||||||
},
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Анотації",
|
"title": "Анотації",
|
||||||
"mediaList": "Медіа",
|
"mediaList": "Медіа файли",
|
||||||
|
"filterByName": "фільтр за назвою…",
|
||||||
"upload": "Завантажити файли",
|
"upload": "Завантажити файли",
|
||||||
"deleteMedia": "Видалити медіа?",
|
"deleteMedia": "Видалити медіа?",
|
||||||
"detect": "AI Розпізнавання",
|
"detect": "AI Розпізнавання",
|
||||||
|
"detectInProgress": "AI РОЗПІЗНАВАННЯ ТРИВАЄ",
|
||||||
"save": "Зберегти",
|
"save": "Зберегти",
|
||||||
"delete": "Видалити",
|
"delete": "Видалити",
|
||||||
"deleteAll": "Видалити все",
|
"deleteAll": "Видалити все",
|
||||||
|
"deleteAllTitle": "Видалити все на кадрі",
|
||||||
"classes": "Класи детекцій",
|
"classes": "Класи детекцій",
|
||||||
"photoMode": "Режим фото",
|
"photoMode": "Режим фото",
|
||||||
"regular": "Звичайний",
|
"regular": "Звичайний",
|
||||||
"winter": "Зимовий",
|
"winter": "Зимовий",
|
||||||
"night": "Нічний"
|
"night": "Нічний",
|
||||||
|
"colName": "НАЗВА",
|
||||||
|
"colKey": "КЛВ",
|
||||||
|
"colNum": "№",
|
||||||
|
"colTime": "ЧАС",
|
||||||
|
"colClass": "КЛАС",
|
||||||
|
"colConf": "ВПЕВ",
|
||||||
|
"canvas": "Канва",
|
||||||
|
"zoom": "ЗУМ",
|
||||||
|
"cursor": "КУРСОР",
|
||||||
|
"frameStep": "КРОК КАДРУ",
|
||||||
|
"frame": "КАДР",
|
||||||
|
"summary": "ПІДСУМОК",
|
||||||
|
"emptyFrame": "порожній кадр",
|
||||||
|
"filter": "Фільтр",
|
||||||
|
"sort": "Сортувати",
|
||||||
|
"play": "Програти",
|
||||||
|
"pause": "Пауза",
|
||||||
|
"previousMedia": "Попереднє медіа",
|
||||||
|
"nextMedia": "Наступне медіа",
|
||||||
|
"back5s": "Назад 5с",
|
||||||
|
"forward5s": "Вперед 5с",
|
||||||
|
"mute": "Без звуку",
|
||||||
|
"selectMedia": "Оберіть файл медіа щоб почати",
|
||||||
|
"annCount_one": "{{count}} анот.",
|
||||||
|
"annCount_few": "{{count}} анот.",
|
||||||
|
"annCount_many": "{{count}} анот.",
|
||||||
|
"annCount_other": "{{count}} анот.",
|
||||||
|
"emptyCount": "{{count}} порожн."
|
||||||
},
|
},
|
||||||
"dataset": {
|
"dataset": {
|
||||||
"title": "Датасет",
|
"title": "Датасет",
|
||||||
@@ -104,12 +212,30 @@
|
|||||||
"editor": "Редактор",
|
"editor": "Редактор",
|
||||||
"classDistribution": "Розподіл класів",
|
"classDistribution": "Розподіл класів",
|
||||||
"objectsOnly": "Тільки з об'єктами",
|
"objectsOnly": "Тільки з об'єктами",
|
||||||
"search": "Пошук...",
|
"hideEmpty": "Приховати порожні кадри",
|
||||||
|
"search": "Пошук за назвою анотації…",
|
||||||
"validate": "Валідувати",
|
"validate": "Валідувати",
|
||||||
|
"edit": "Редагувати",
|
||||||
|
"filters": "Фільтри",
|
||||||
|
"total": "Всього",
|
||||||
|
"validatedCount": "Валідовано",
|
||||||
|
"range": "Діапазон",
|
||||||
|
"flight": "Політ",
|
||||||
|
"showing": "Показано",
|
||||||
|
"liveSync": "Жива синхронізація",
|
||||||
|
"selected": "Вибрано",
|
||||||
|
"refreshThumbnails": "Оновити мініатюри",
|
||||||
|
"ofSelected": "{{count}} з {{total}} вибрано",
|
||||||
|
"local": "Локально",
|
||||||
|
"sort": "Сортування",
|
||||||
|
"gridDensity": "Щільність сітки",
|
||||||
|
"statusLabel": "Статус",
|
||||||
"status": {
|
"status": {
|
||||||
"created": "Створено",
|
"created": "Створено",
|
||||||
"edited": "Відредаговано",
|
"edited": "Відредаговано",
|
||||||
"validated": "Валідовано"
|
"validated": "Валідовано",
|
||||||
|
"all": "Всі",
|
||||||
|
"none": "Жоден"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
+179
@@ -444,3 +444,182 @@ select.inp {
|
|||||||
::-webkit-scrollbar-track { background: var(--surface-0); }
|
::-webkit-scrollbar-track { background: var(--surface-0); }
|
||||||
::-webkit-scrollbar-thumb { background: #1f2630; border-radius: 2px; }
|
::-webkit-scrollbar-thumb { background: #1f2630; border-radius: 2px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: #2a323e; }
|
::-webkit-scrollbar-thumb:hover { background: #2a323e; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
ANNOTATIONS PAGE — v2 surfaces
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
/* Splitter affordance between resizable panes */
|
||||||
|
.split { width: 4px; cursor: col-resize; background: transparent; position: relative; }
|
||||||
|
.split::after {
|
||||||
|
content: ''; position: absolute; left: 1px; top: 0; bottom: 0; width: 1px;
|
||||||
|
background: var(--border-hair);
|
||||||
|
}
|
||||||
|
.split:hover::after { background: var(--accent-amber); }
|
||||||
|
|
||||||
|
/* Media list row (264px left aside) */
|
||||||
|
.media-row {
|
||||||
|
position: relative;
|
||||||
|
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
height: 32px; padding: 0 12px 0 14px;
|
||||||
|
border-bottom: 1px solid var(--border-hair);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.media-row:hover { background: var(--surface-2); }
|
||||||
|
.media-row.active { background: var(--surface-2); }
|
||||||
|
.media-row.active::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
|
width: 2px; background: var(--accent-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type chips inside media rows */
|
||||||
|
.chip-photo {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 40px; height: 16px; border-radius: 2px;
|
||||||
|
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||||
|
color: var(--accent-cyan); border: 1px solid rgba(54,214,197,0.45);
|
||||||
|
background: rgba(54,214,197,0.06);
|
||||||
|
}
|
||||||
|
.chip-video {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 40px; height: 16px; border-radius: 2px;
|
||||||
|
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||||
|
color: var(--accent-amber); border: 1px solid rgba(255,157,61,0.45);
|
||||||
|
background: rgba(255,157,61,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detection class row */
|
||||||
|
.class-row {
|
||||||
|
display: grid; grid-template-columns: 16px 1fr auto; gap: 10px;
|
||||||
|
align-items: center; height: 28px; padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--border-hair);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.class-row:hover { background: var(--surface-2); }
|
||||||
|
.class-row.active { background: var(--surface-2); }
|
||||||
|
.class-row.active .kbd { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||||
|
|
||||||
|
/* Keycap chip */
|
||||||
|
.kbd {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 18px; height: 16px; padding: 0;
|
||||||
|
font: 600 10px/1 'JetBrains Mono', monospace;
|
||||||
|
color: var(--text-muted); border: 1px solid var(--border-hair); border-radius: 2px;
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Annotation row in right sidebar (gradient stripe via --row-grad) */
|
||||||
|
.ann-row {
|
||||||
|
position: relative;
|
||||||
|
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px; padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--border-hair);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
}
|
||||||
|
.ann-row::after {
|
||||||
|
content: ''; position: absolute; left: 0; right: 0; top: 0; bottom: 0;
|
||||||
|
background-image: var(--row-grad, none);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ann-row > * { position: relative; z-index: 1; }
|
||||||
|
.ann-row:hover { background-color: var(--surface-2); }
|
||||||
|
.ann-row.active { background-color: var(--surface-2); }
|
||||||
|
|
||||||
|
/* Faux terrain wash behind canvas */
|
||||||
|
.terrain {
|
||||||
|
background-color: #11181B;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(900px 500px at 30% 40%, rgba(48,72,60,0.45), transparent 60%),
|
||||||
|
radial-gradient(700px 400px at 75% 65%, rgba(40,52,68,0.35), transparent 65%),
|
||||||
|
radial-gradient(400px 300px at 60% 30%, rgba(82,64,40,0.18), transparent 70%),
|
||||||
|
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||||
|
background-size: auto, auto, auto, 48px 48px, 48px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating AI Detection banner over canvas */
|
||||||
|
.ai-banner {
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
background: rgba(10,13,16,0.78);
|
||||||
|
border: 1px solid rgba(54,214,197,0.4);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bounding-box label chip (DOM overlay on canvas) */
|
||||||
|
.bbox-label {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
height: 22px; padding: 0 8px;
|
||||||
|
font: 600 10px/1 'JetBrains Mono', monospace; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(10,13,16,0.92);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-hair);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bbox-label .conf { color: var(--text-secondary); font-weight: 500; }
|
||||||
|
|
||||||
|
/* Selection handles on bounding boxes */
|
||||||
|
.handle {
|
||||||
|
position: absolute; width: 6px; height: 6px;
|
||||||
|
background: var(--accent-amber); border: 1px solid #0A0D10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrubber (timeline with annotation marks) */
|
||||||
|
.scrub {
|
||||||
|
height: 4px; background: var(--surface-2); border: 1px solid var(--border-hair);
|
||||||
|
border-radius: 2px; position: relative; cursor: pointer;
|
||||||
|
}
|
||||||
|
.scrub .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent-amber); pointer-events: none; }
|
||||||
|
.scrub .head {
|
||||||
|
position: absolute; top: 50%; width: 2px; height: 10px; background: var(--accent-amber);
|
||||||
|
transform: translate(-50%, -50%); pointer-events: none;
|
||||||
|
}
|
||||||
|
.scrub .head-knob {
|
||||||
|
position: absolute; top: 50%; width: 12px; height: 12px;
|
||||||
|
background: var(--accent-amber);
|
||||||
|
border: 2px solid var(--surface-1);
|
||||||
|
border-radius: 999px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent-amber), 0 0 8px rgba(255,157,61,0.45);
|
||||||
|
z-index: 2;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.scrub .head-knob:active { cursor: grabbing; }
|
||||||
|
.scrub .tick {
|
||||||
|
position: absolute; top: 50%; width: 1px; height: 6px; background: var(--text-muted);
|
||||||
|
transform: translateY(-50%); pointer-events: none;
|
||||||
|
}
|
||||||
|
.scrub .mark {
|
||||||
|
position: absolute; top: -3px; width: 2px; height: 10px; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume slider (range input next to mute) */
|
||||||
|
.vol {
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
height: 2px; width: 72px; background: var(--border-hair); outline: none; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.vol::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.vol::-moz-range-thumb {
|
||||||
|
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer; border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live pulse dot (cyan glow) — annotations LIVE indicators */
|
||||||
|
.live-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 999px;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 0 rgba(54,214,197,0.5);
|
||||||
|
animation: live-pulse 1.6s ease-in-out infinite;
|
||||||
|
display: inline-block; flex: none;
|
||||||
|
}
|
||||||
|
@keyframes live-pulse {
|
||||||
|
0%,100% { box-shadow: 0 0 0 0 rgba(54,214,197,0.5); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(54,214,197,0); }
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -8,6 +8,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_OWM_API_KEY?: string
|
readonly VITE_OWM_API_KEY?: string
|
||||||
readonly VITE_OWM_BASE_URL?: string
|
readonly VITE_OWM_BASE_URL?: string
|
||||||
readonly VITE_SATELLITE_TILE_URL?: string
|
readonly VITE_SATELLITE_TILE_URL?: string
|
||||||
|
/** Dev-only: when 'true', skip backend auth and inject a fake admin user. */
|
||||||
|
readonly VITE_DEV_AUTH_BYPASS?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"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/components/savedannotationscontext.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/annotations/classcolors.ts","./src/features/annotations/thumbnail.ts","./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"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/index.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/auth/index.ts","./src/class-colors/classcolors.ts","./src/class-colors/index.ts","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/components/savedannotationscontext.tsx","./src/components/index.ts","./src/features/admin/adminpage.tsx","./src/features/admin/classeditrow.tsx","./src/features/admin/modal.tsx","./src/features/admin/numberstepper.tsx","./src/features/admin/index.ts","./src/features/admin/useaisettings.ts","./src/features/admin/usegpssettings.ts","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/scrubber.tsx","./src/features/annotations/videoplayer.tsx","./src/features/annotations/index.ts","./src/features/annotations/thumbnail.ts","./src/features/annotations/time.ts","./src/features/dataset/datasetleftpanel.tsx","./src/features/dataset/datasetpage.tsx","./src/features/dataset/index.ts","./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/index.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/login/index.ts","./src/features/settings/settingspage.tsx","./src/features/settings/index.ts","./src/hooks/index.ts","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/i18n/index.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||||
Reference in New Issue
Block a user