diff --git a/src/components/SavedAnnotationsContext.tsx b/src/components/SavedAnnotationsContext.tsx new file mode 100644 index 0000000..9c1282e --- /dev/null +++ b/src/components/SavedAnnotationsContext.tsx @@ -0,0 +1,81 @@ +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' +import { AnnotationSource, AnnotationStatus } from '../types' +import type { Detection } from '../types' + +export interface SavedDetection { + id: string + annotationLocalId: string + mediaId: string + mediaName: string + thumbnail: string + fullFrame: string + status: AnnotationStatus + source: AnnotationSource + createdDate: string + detection: Detection + time: string | null + flightId: string | null +} + +interface SavedAnnotationsState { + saved: SavedDetection[] + addMany: (items: SavedDetection[]) => void + replaceGroup: (annotationLocalId: string, items: SavedDetection[]) => void + updateStatus: (ids: string[], status: AnnotationStatus) => void + removeSaved: (id: string) => void + clear: () => void +} + +const STORAGE_KEY = 'az.savedAnnotations.v2' + +const SavedAnnotationsContext = createContext(null!) + +export function useSavedAnnotations() { + return useContext(SavedAnnotationsContext) +} + +export function SavedAnnotationsProvider({ children }: { children: ReactNode }) { + const [saved, setSaved] = useState(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as SavedDetection[]) : [] + } catch { + return [] + } + }) + + useEffect(() => { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)) } catch {} + }, [saved]) + + const addMany = useCallback((items: SavedDetection[]) => { + if (!items.length) return + const ids = new Set(items.map(i => i.id)) + setSaved(prev => [...items, ...prev.filter(x => !ids.has(x.id))]) + }, []) + + const replaceGroup = useCallback((annotationLocalId: string, items: SavedDetection[]) => { + setSaved(prev => [ + ...items, + ...prev.filter(x => x.annotationLocalId !== annotationLocalId), + ]) + }, []) + + const updateStatus = useCallback((ids: string[], status: AnnotationStatus) => { + if (!ids.length) return + const idSet = new Set(ids) + setSaved(prev => prev.map(x => idSet.has(x.id) ? { ...x, status } : x)) + }, []) + + const removeSaved = useCallback((id: string) => { + setSaved(prev => prev.filter(x => x.id !== id)) + }, []) + + const clear = useCallback(() => setSaved([]), []) + + return ( + + {children} + + ) +} diff --git a/src/features/annotations/AnnotationsPage.tsx b/src/features/annotations/AnnotationsPage.tsx index 9c8d9e8..212fb93 100644 --- a/src/features/annotations/AnnotationsPage.tsx +++ b/src/features/annotations/AnnotationsPage.tsx @@ -6,8 +6,11 @@ import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer' import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor' import AnnotationsSidebar from './AnnotationsSidebar' import DetectionClasses from '../../components/DetectionClasses' +import { useSavedAnnotations } from '../../components/SavedAnnotationsContext' +import { useFlight } from '../../components/FlightContext' import { AnnotationSource, AnnotationStatus, MediaType } from '../../types' import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors' +import { captureThumbnails } from './thumbnail' import type { Media, AnnotationListItem, Detection } from '../../types' export default function AnnotationsPage() { @@ -22,6 +25,8 @@ export default function AnnotationsPage() { const rightPanel = useResizablePanel(200, 150, 350) const videoPlayerRef = useRef(null) const canvasRef = useRef(null) + const { addMany } = useSavedAnnotations() + const { selectedFlight } = useFlight() useEffect(() => { setDetections([]) @@ -34,6 +39,30 @@ export default function AnnotationsPage() { const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null const body = { mediaId: selectedMedia.id, time, detections } + const { fullFrame, detectionThumbnails } = await captureThumbnails( + selectedMedia, + videoPlayerRef.current?.getVideoElement() ?? null, + detections, + ) + + const pushToStore = (annotationLocalId: string) => { + const createdDate = new Date().toISOString() + addMany(detections.map((d, i) => ({ + id: `${annotationLocalId}:${d.id ?? i}`, + annotationLocalId, + mediaId: selectedMedia.id, + mediaName: selectedMedia.name, + thumbnail: detectionThumbnails[i] ?? '', + fullFrame, + status: AnnotationStatus.Created, + source: AnnotationSource.Manual, + createdDate, + detection: d, + time, + flightId: selectedFlight?.id ?? null, + }))) + } + if (!selectedMedia.path.startsWith('blob:')) { try { await api.post('/api/annotations/annotations', body) @@ -41,6 +70,7 @@ export default function AnnotationsPage() { `/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`, ) setAnnotations(res.items) + pushToStore(`saved-${crypto.randomUUID()}`) return } catch { // fall through to local save @@ -60,7 +90,8 @@ export default function AnnotationsPage() { detections: [...detections], } setAnnotations(prev => [...prev, local]) - }, [selectedMedia, detections, currentTime]) + pushToStore(local.id) + }, [selectedMedia, detections, currentTime, addMany, selectedFlight]) const handleDownload = useCallback(async (ann: AnnotationListItem) => { if (!selectedMedia) return diff --git a/src/features/annotations/CanvasEditor.tsx b/src/features/annotations/CanvasEditor.tsx index 649e6a3..9e78b90 100644 --- a/src/features/annotations/CanvasEditor.tsx +++ b/src/features/annotations/CanvasEditor.tsx @@ -75,16 +75,29 @@ const CanvasEditor = forwardRef(function CanvasEditor } const img = new Image() img.crossOrigin = 'anonymous' - if (annotation && !media.path.startsWith('blob:')) { + const isLocalPath = media.path.startsWith('blob:') || media.path.startsWith('data:') + if (annotation && !isLocalPath) { img.src = `/api/annotations/annotations/${annotation.id}/image` - } else if (media.path.startsWith('blob:')) { + } else if (isLocalPath) { img.src = media.path } else { img.src = `/api/annotations/media/${media.id}/file` } img.onload = () => { imgRef.current = img - setImgSize({ w: img.naturalWidth, h: img.naturalHeight }) + const w = img.naturalWidth + const h = img.naturalHeight + setImgSize({ w, h }) + const c = containerRef.current + if (c && w && h) { + const fit = Math.min(c.clientWidth / w, c.clientHeight / h) + const clamped = Math.max(0.05, Math.min(10, fit)) + setZoom(clamped) + setPan({ + x: (c.clientWidth - w * clamped) / 2, + y: (c.clientHeight - h * clamped) / 2, + }) + } } }, [media, annotation, isVideo]) diff --git a/src/features/annotations/thumbnail.ts b/src/features/annotations/thumbnail.ts new file mode 100644 index 0000000..b40ebec --- /dev/null +++ b/src/features/annotations/thumbnail.ts @@ -0,0 +1,138 @@ +import { MediaType } from '../../types' +import type { Detection, Media } from '../../types' +import { getClassColor } from './classColors' + +const THUMB_MAX = 240 +const CROP_PAD = 0.15 +const FULL_FRAME_MAX = 1280 + +async function getSourceCanvas( + media: Media, + videoEl: HTMLVideoElement | null, +): Promise<{ canvas: HTMLCanvasElement; w: number; h: number } | null> { + const canvas = document.createElement('canvas') + + if (media.mediaType === MediaType.Video && videoEl && videoEl.videoWidth) { + const w = videoEl.videoWidth + const h = videoEl.videoHeight + canvas.width = w + canvas.height = h + canvas.getContext('2d')?.drawImage(videoEl, 0, 0, w, h) + return { canvas, w, h } + } + + if (media.mediaType === MediaType.Image) { + const img = new Image() + img.crossOrigin = 'anonymous' + img.src = media.path.startsWith('blob:') + ? media.path + : `/api/annotations/media/${media.id}/file` + await new Promise(resolve => { + img.onload = () => resolve() + img.onerror = () => resolve() + }) + if (!img.naturalWidth) return null + const w = img.naturalWidth + const h = img.naturalHeight + canvas.width = w + canvas.height = h + canvas.getContext('2d')?.drawImage(img, 0, 0, w, h) + return { canvas, w, h } + } + + return null +} + +export interface ThumbnailCapture { + fullFrame: string + detectionThumbnails: string[] +} + +export async function captureThumbnails( + media: Media, + videoEl: HTMLVideoElement | null, + detections: Detection[], +): Promise { + const src = await getSourceCanvas(media, videoEl) + if (!src) return { fullFrame: '', detectionThumbnails: detections.map(() => '') } + + const fullScale = Math.min(1, FULL_FRAME_MAX / src.w) + const full = document.createElement('canvas') + full.width = Math.max(1, Math.round(src.w * fullScale)) + full.height = Math.max(1, Math.round(src.h * fullScale)) + full.getContext('2d')?.drawImage(src.canvas, 0, 0, full.width, full.height) + const fullFrame = full.toDataURL('image/jpeg', 0.85) + + const detectionThumbnails = detections.map(d => cropDetection(src, d)) + + return { fullFrame, detectionThumbnails } +} + +function cropDetection( + src: { canvas: HTMLCanvasElement; w: number; h: number }, + d: Detection, +): string { + const cxPx = d.centerX * src.w + const cyPx = d.centerY * src.h + const bw = d.width * src.w + const bh = d.height * src.h + const side = Math.max(bw, bh) * (1 + CROP_PAD * 2) + + const sx = cxPx - side / 2 + const sy = cyPx - side / 2 + + const ix0 = Math.max(0, Math.floor(sx)) + const iy0 = Math.max(0, Math.floor(sy)) + const ix1 = Math.min(src.w, Math.ceil(sx + side)) + const iy1 = Math.min(src.h, Math.ceil(sy + side)) + const iw = Math.max(1, ix1 - ix0) + const ih = Math.max(1, iy1 - iy0) + + const out = document.createElement('canvas') + out.width = THUMB_MAX + out.height = THUMB_MAX + const ctx = out.getContext('2d') + if (ctx) { + ctx.fillStyle = '#1e1e1e' + ctx.fillRect(0, 0, THUMB_MAX, THUMB_MAX) + + const scale = THUMB_MAX / side + ctx.drawImage( + src.canvas, + ix0, iy0, iw, ih, + (ix0 - sx) * scale, (iy0 - sy) * scale, iw * scale, ih * scale, + ) + + const bx = cxPx - bw / 2 + const by = cyPx - bh / 2 + ctx.strokeStyle = getClassColor(d.classNum) + ctx.lineWidth = Math.max(2, THUMB_MAX / 100) + ctx.strokeRect( + (bx - sx) * scale, + (by - sy) * scale, + bw * scale, + bh * scale, + ) + } + return out.toDataURL('image/jpeg', 0.8) +} + +export async function recaptureThumbnails( + fullFrameDataUrl: string, + detections: Detection[], +): Promise { + if (!fullFrameDataUrl) return detections.map(() => '') + const img = new Image() + img.src = fullFrameDataUrl + await new Promise(resolve => { + img.onload = () => resolve() + img.onerror = () => resolve() + }) + if (!img.naturalWidth) return detections.map(() => '') + const canvas = document.createElement('canvas') + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + canvas.getContext('2d')?.drawImage(img, 0, 0) + const src = { canvas, w: canvas.width, h: canvas.height } + return detections.map(d => cropDetection(src, d)) +} diff --git a/src/features/dataset/DatasetPage.tsx b/src/features/dataset/DatasetPage.tsx index 074c23f..8ce66e8 100644 --- a/src/features/dataset/DatasetPage.tsx +++ b/src/features/dataset/DatasetPage.tsx @@ -1,20 +1,39 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { FaPen } from 'react-icons/fa' import { api } from '../../api/client' import { useDebounce } from '../../hooks/useDebounce' import { useResizablePanel } from '../../hooks/useResizablePanel' import { useFlight } from '../../components/FlightContext' +import { useSavedAnnotations } from '../../components/SavedAnnotationsContext' import DetectionClasses from '../../components/DetectionClasses' -import ConfirmDialog from '../../components/ConfirmDialog' import CanvasEditor from '../annotations/CanvasEditor' +import { recaptureThumbnails } from '../annotations/thumbnail' +import type { SavedDetection } from '../../components/SavedAnnotationsContext' import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types' -import { AnnotationStatus } from '../../types' +import { AnnotationSource, AnnotationStatus } from '../../types' + +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 +} type Tab = 'annotations' | 'editor' | 'distribution' export default function DatasetPage() { const { t } = useTranslation() const { selectedFlight } = useFlight() + const { saved: savedAnnotations, removeSaved, replaceGroup, updateStatus } = useSavedAnnotations() const leftPanel = useResizablePanel(250, 200, 400) const [items, setItems] = useState([]) @@ -53,21 +72,131 @@ export default function DatasetPage() { useEffect(() => { fetchItems() }, [fetchItems]) - const handleDoubleClick = async (item: DatasetItem) => { + const cards = useMemo(() => { + const localCards: DatasetCard[] = savedAnnotations + .filter(sd => { + if (selectedFlight && sd.flightId && sd.flightId !== selectedFlight.id) return false + if (statusFilter !== null && sd.status !== statusFilter) return false + if (selectedClassNum && sd.detection.classNum !== selectedClassNum) return false + if (debouncedSearch && !sd.mediaName.toLowerCase().includes(debouncedSearch.toLowerCase())) return false + if (fromDate && sd.createdDate < fromDate) return false + if (toDate && sd.createdDate > `${toDate}T23:59:59`) return false + return true + }) + .map(sd => ({ + annotationId: sd.id, + imageName: sd.mediaName, + status: sd.status, + createdDate: sd.createdDate, + thumbnailUrl: sd.thumbnail, + isSeed: false, + isLocal: true, + detections: [sd.detection], + mediaId: sd.mediaId, + time: sd.time, + fullFrame: sd.fullFrame, + annotationLocalId: sd.annotationLocalId, + })) + + const remoteCards: DatasetCard[] = items.map(item => ({ + annotationId: item.annotationId, + imageName: item.imageName, + status: item.status, + createdDate: item.createdDate, + thumbnailUrl: `/api/annotations/annotations/${item.annotationId}/thumbnail`, + isSeed: item.isSeed, + isLocal: false, + })) + + return [...localCards, ...remoteCards] + }, [savedAnnotations, items, selectedFlight, statusFilter, objectsOnly, selectedClassNum, debouncedSearch, fromDate, toDate]) + + const [editorFullFrame, setEditorFullFrame] = useState('') + const [editorLocalGroupId, setEditorLocalGroupId] = useState(null) + const [editorSaving, setEditorSaving] = useState(false) + + const handleDoubleClick = async (card: DatasetCard) => { + if (card.isLocal && card.detections && card.mediaId) { + setEditorAnnotation({ + id: card.annotationId, + mediaId: card.mediaId, + time: card.time ?? null, + createdDate: card.createdDate, + userId: 'local', + source: AnnotationSource.Manual, + status: card.status, + isSplit: false, + splitTile: null, + detections: card.detections, + }) + setEditorDetections(card.detections) + setEditorFullFrame(card.fullFrame ?? '') + setEditorLocalGroupId(card.annotationLocalId ?? null) + setTab('editor') + return + } + setEditorFullFrame('') + setEditorLocalGroupId(null) try { - const ann = await api.get(`/api/annotations/dataset/${item.annotationId}`) + const ann = await api.get(`/api/annotations/dataset/${card.annotationId}`) setEditorAnnotation(ann) setEditorDetections(ann.detections) setTab('editor') } catch {} } + const handleEditorSave = async () => { + if (!editorAnnotation) return + setEditorSaving(true) + try { + if (editorLocalGroupId) { + const existing = savedAnnotations.find(s => s.annotationLocalId === editorLocalGroupId) + const thumbs = await recaptureThumbnails(editorFullFrame, editorDetections) + const now = new Date().toISOString() + const items: SavedDetection[] = editorDetections.map((d, i) => ({ + id: `${editorLocalGroupId}:${d.id ?? i}`, + annotationLocalId: editorLocalGroupId, + mediaId: editorAnnotation.mediaId, + mediaName: existing?.mediaName ?? '', + thumbnail: thumbs[i] ?? '', + fullFrame: editorFullFrame, + status: AnnotationStatus.Edited, + source: existing?.source ?? AnnotationSource.Manual, + createdDate: existing?.createdDate ?? now, + detection: d, + time: editorAnnotation.time, + flightId: existing?.flightId ?? null, + })) + replaceGroup(editorLocalGroupId, items) + } + setTab('annotations') + } finally { + setEditorSaving(false) + } + } + + const handleEditorCancel = () => { + if (editorAnnotation) setEditorDetections(editorAnnotation.detections) + setTab('annotations') + } + const handleValidate = async () => { if (selectedIds.size === 0) return - await api.post('/api/annotations/dataset/bulk-status', { - annotationIds: Array.from(selectedIds), - status: AnnotationStatus.Validated, - }) + const allIds = Array.from(selectedIds) + const localIds = allIds.filter(id => id.startsWith('saved-') || id.startsWith('local-')) + const backendIds = allIds.filter(id => !id.startsWith('saved-') && !id.startsWith('local-')) + + if (backendIds.length > 0) { + try { + await api.post('/api/annotations/dataset/bulk-status', { + annotationIds: backendIds, + status: AnnotationStatus.Validated, + }) + } catch {} + } + if (localIds.length > 0) { + updateStatus(localIds, AnnotationStatus.Validated) + } setSelectedIds(new Set()) fetchItems() } @@ -85,7 +214,7 @@ export default function DatasetPage() { const totalPages = Math.ceil(totalCount / pageSize) const editorMedia: Media | null = editorAnnotation ? { - id: editorAnnotation.mediaId, name: '', path: '', mediaType: 1, mediaStatus: 0, + id: editorAnnotation.mediaId, name: '', path: editorFullFrame, mediaType: 1, mediaStatus: 0, duration: null, annotationCount: 0, waypointId: null, userId: '', } : null @@ -124,7 +253,7 @@ export default function DatasetPage() {
{/* Main area */} -
+
{/* Filter bar */}
setFromDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" /> @@ -163,49 +292,70 @@ export default function DatasetPage() { {tab === 'annotations' && (
- {items.map(item => ( -
{ - if (e.ctrlKey) { - setSelectedIds(prev => { - const n = new Set(prev) - n.has(item.annotationId) ? n.delete(item.annotationId) : n.add(item.annotationId) - return n - }) - } else { - setSelectedIds(new Set([item.annotationId])) - } - }} - onDoubleClick={() => handleDoubleClick(item)} - className={`bg-az-panel border rounded overflow-hidden cursor-pointer ${ - selectedIds.has(item.annotationId) ? 'border-az-orange' : 'border-az-border' - } ${item.isSeed ? 'ring-2 ring-az-red' : ''}`} - > - {item.imageName} -
-
{item.imageName}
-
- {new Date(item.createdDate).toLocaleDateString()} - - {item.status === AnnotationStatus.Validated ? t('dataset.status.validated') : - item.status === AnnotationStatus.Edited ? t('dataset.status.edited') : - t('dataset.status.created')} + {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 ( +
{ + if (e.ctrlKey) { + setSelectedIds(prev => { + const n = new Set(prev) + n.has(card.annotationId) ? n.delete(card.annotationId) : n.add(card.annotationId) + return n + }) + } else { + setSelectedIds(new Set([card.annotationId])) + } + }} + onDoubleClick={() => handleDoubleClick(card)} + onContextMenu={e => { + if (!card.isLocal) return + e.preventDefault() + removeSaved(card.annotationId) + }} + title={card.imageName} + 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 ? ( + {card.imageName} + ) : ( +
+ )} + + {statusPill.label} + + {card.isLocal && ( + + local -
+ )} +
-
- ))} + ) + })}
+ {cards.length === 0 && ( +
{t('common.noData')}
+ )} {/* Pagination */} {totalPages > 1 && (
@@ -218,33 +368,66 @@ export default function DatasetPage() { )} {tab === 'editor' && editorMedia && editorAnnotation && ( -
- +
+
+
+ + + + {editorDetections.length} detection{editorDetections.length !== 1 ? 's' : ''} + + {!editorLocalGroupId && ( + + remote save not wired yet + + )} +
+
+
+ +
+
+
)} {tab === 'distribution' && ( -
-
- {distribution.map(d => ( -
- - {d.label} -
-
+
+ {distribution.map(d => { + const pct = (d.count / maxDistCount) * 100 + return ( +
+
+
+ {d.label}: {d.count} + {d.count}
- {d.count}
- ))} -
+ ) + })}
)}
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index b457a5c..ce55d93 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/dataset/datasetpage.tsx","./src/features/flights/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"} \ No newline at end of file +{"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"} \ No newline at end of file