feat(dataset): per-detection cards, in-browser editor, bulk-validate for local saves

This commit is contained in:
Armen Rohalov
2026-04-24 00:49:08 +03:00
parent 1fa749382f
commit b0829b4a90
6 changed files with 523 additions and 77 deletions
@@ -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<SavedAnnotationsState>(null!)
export function useSavedAnnotations() {
return useContext(SavedAnnotationsContext)
}
export function SavedAnnotationsProvider({ children }: { children: ReactNode }) {
const [saved, setSaved] = useState<SavedDetection[]>(() => {
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 (
<SavedAnnotationsContext.Provider value={{ saved, addMany, replaceGroup, updateStatus, removeSaved, clear }}>
{children}
</SavedAnnotationsContext.Provider>
)
}
+32 -1
View File
@@ -6,8 +6,11 @@ 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 DetectionClasses from '../../components/DetectionClasses' import DetectionClasses from '../../components/DetectionClasses'
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
import { useFlight } from '../../components/FlightContext'
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types' import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors' import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
import { captureThumbnails } from './thumbnail'
import type { Media, AnnotationListItem, Detection } from '../../types' import type { Media, AnnotationListItem, Detection } from '../../types'
export default function AnnotationsPage() { export default function AnnotationsPage() {
@@ -22,6 +25,8 @@ export default function AnnotationsPage() {
const rightPanel = useResizablePanel(200, 150, 350) const rightPanel = useResizablePanel(200, 150, 350)
const videoPlayerRef = useRef<VideoPlayerHandle>(null) const videoPlayerRef = useRef<VideoPlayerHandle>(null)
const canvasRef = useRef<CanvasEditorHandle>(null) const canvasRef = useRef<CanvasEditorHandle>(null)
const { addMany } = useSavedAnnotations()
const { selectedFlight } = useFlight()
useEffect(() => { useEffect(() => {
setDetections([]) setDetections([])
@@ -34,6 +39,30 @@ export default function AnnotationsPage() {
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
const body = { mediaId: selectedMedia.id, time, detections } 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:')) { if (!selectedMedia.path.startsWith('blob:')) {
try { try {
await api.post('/api/annotations/annotations', body) await api.post('/api/annotations/annotations', body)
@@ -41,6 +70,7 @@ export default function AnnotationsPage() {
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`, `/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
) )
setAnnotations(res.items) setAnnotations(res.items)
pushToStore(`saved-${crypto.randomUUID()}`)
return return
} catch { } catch {
// fall through to local save // fall through to local save
@@ -60,7 +90,8 @@ export default function AnnotationsPage() {
detections: [...detections], detections: [...detections],
} }
setAnnotations(prev => [...prev, local]) setAnnotations(prev => [...prev, local])
}, [selectedMedia, detections, currentTime]) pushToStore(local.id)
}, [selectedMedia, detections, currentTime, addMany, selectedFlight])
const handleDownload = useCallback(async (ann: AnnotationListItem) => { const handleDownload = useCallback(async (ann: AnnotationListItem) => {
if (!selectedMedia) return if (!selectedMedia) return
+16 -3
View File
@@ -75,16 +75,29 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
} }
const img = new Image() const img = new Image()
img.crossOrigin = 'anonymous' 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` img.src = `/api/annotations/annotations/${annotation.id}/image`
} else if (media.path.startsWith('blob:')) { } else if (isLocalPath) {
img.src = media.path img.src = media.path
} else { } else {
img.src = `/api/annotations/media/${media.id}/file` img.src = `/api/annotations/media/${media.id}/file`
} }
img.onload = () => { img.onload = () => {
imgRef.current = img 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]) }, [media, annotation, isVideo])
+138
View File
@@ -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<void>(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<ThumbnailCapture> {
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<string[]> {
if (!fullFrameDataUrl) return detections.map(() => '')
const img = new Image()
img.src = fullFrameDataUrl
await new Promise<void>(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))
}
+229 -46
View File
@@ -1,20 +1,39 @@
import { useState, useEffect, useCallback } 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 } from '../../api/client' import { api } from '../../api/client'
import { useDebounce } from '../../hooks/useDebounce' import { useDebounce } from '../../hooks/useDebounce'
import { useResizablePanel } from '../../hooks/useResizablePanel' import { useResizablePanel } from '../../hooks/useResizablePanel'
import { useFlight } from '../../components/FlightContext' import { useFlight } from '../../components/FlightContext'
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
import DetectionClasses from '../../components/DetectionClasses' import DetectionClasses from '../../components/DetectionClasses'
import ConfirmDialog from '../../components/ConfirmDialog'
import CanvasEditor from '../annotations/CanvasEditor' 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 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' type Tab = 'annotations' | 'editor' | 'distribution'
export default function DatasetPage() { export default function DatasetPage() {
const { t } = useTranslation() const { t } = useTranslation()
const { selectedFlight } = useFlight() const { selectedFlight } = useFlight()
const { saved: savedAnnotations, removeSaved, replaceGroup, updateStatus } = useSavedAnnotations()
const leftPanel = useResizablePanel(250, 200, 400) const leftPanel = useResizablePanel(250, 200, 400)
const [items, setItems] = useState<DatasetItem[]>([]) const [items, setItems] = useState<DatasetItem[]>([])
@@ -53,21 +72,131 @@ export default function DatasetPage() {
useEffect(() => { fetchItems() }, [fetchItems]) useEffect(() => { fetchItems() }, [fetchItems])
const handleDoubleClick = async (item: DatasetItem) => { const cards = useMemo<DatasetCard[]>(() => {
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<string>('')
const [editorLocalGroupId, setEditorLocalGroupId] = useState<string | null>(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 { try {
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${item.annotationId}`) const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${card.annotationId}`)
setEditorAnnotation(ann) setEditorAnnotation(ann)
setEditorDetections(ann.detections) setEditorDetections(ann.detections)
setTab('editor') setTab('editor')
} catch {} } 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 () => { const handleValidate = async () => {
if (selectedIds.size === 0) return if (selectedIds.size === 0) return
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', { await api.post('/api/annotations/dataset/bulk-status', {
annotationIds: Array.from(selectedIds), annotationIds: backendIds,
status: AnnotationStatus.Validated, status: AnnotationStatus.Validated,
}) })
} catch {}
}
if (localIds.length > 0) {
updateStatus(localIds, AnnotationStatus.Validated)
}
setSelectedIds(new Set()) setSelectedIds(new Set())
fetchItems() fetchItems()
} }
@@ -85,7 +214,7 @@ export default function DatasetPage() {
const totalPages = Math.ceil(totalCount / pageSize) const totalPages = Math.ceil(totalCount / pageSize)
const editorMedia: Media | null = editorAnnotation ? { 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: '', duration: null, annotationCount: 0, waypointId: null, userId: '',
} : null } : null
@@ -124,7 +253,7 @@ export default function DatasetPage() {
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" /> <div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
{/* Main area */} {/* Main area */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
{/* Filter bar */} {/* Filter bar */}
<div className="flex items-center gap-2 p-2 border-b border-az-border bg-az-panel text-xs flex-wrap"> <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={fromDate} onChange={e => 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' && ( {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))' }}> <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
{items.map(item => ( {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
key={item.annotationId} key={card.annotationId}
onClick={e => { onClick={e => {
if (e.ctrlKey) { if (e.ctrlKey) {
setSelectedIds(prev => { setSelectedIds(prev => {
const n = new Set(prev) const n = new Set(prev)
n.has(item.annotationId) ? n.delete(item.annotationId) : n.add(item.annotationId) n.has(card.annotationId) ? n.delete(card.annotationId) : n.add(card.annotationId)
return n return n
}) })
} else { } else {
setSelectedIds(new Set([item.annotationId])) setSelectedIds(new Set([card.annotationId]))
} }
}} }}
onDoubleClick={() => handleDoubleClick(item)} onDoubleClick={() => handleDoubleClick(card)}
className={`bg-az-panel border rounded overflow-hidden cursor-pointer ${ onContextMenu={e => {
selectedIds.has(item.annotationId) ? 'border-az-orange' : 'border-az-border' if (!card.isLocal) return
} ${item.isSeed ? 'ring-2 ring-az-red' : ''}`} 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 ? (
<img <img
src={`/api/annotations/annotations/${item.annotationId}/thumbnail`} src={card.thumbnailUrl}
alt={item.imageName} alt={card.imageName}
className="w-full h-32 object-cover bg-az-bg" className="w-full h-full object-cover bg-az-bg"
loading="lazy" loading="lazy"
/> />
<div className="p-1.5 text-xs"> ) : (
<div className="truncate text-az-text">{item.imageName}</div> <div className="w-full h-full bg-az-bg" />
<div className="flex justify-between"> )}
<span className="text-az-muted">{new Date(item.createdDate).toLocaleDateString()}</span> <span className={`absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full ${statusPill.cls}`}>
<span className={`px-1 rounded ${ {statusPill.label}
item.status === AnnotationStatus.Validated ? 'bg-az-green/20 text-az-green' :
item.status === AnnotationStatus.Edited ? 'bg-az-blue/20 text-az-blue' :
'bg-az-muted/20 text-az-muted'
}`}>
{item.status === AnnotationStatus.Validated ? t('dataset.status.validated') :
item.status === AnnotationStatus.Edited ? t('dataset.status.edited') :
t('dataset.status.created')}
</span> </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> </div>
</div> {cards.length === 0 && (
))} <div className="text-center text-az-muted text-xs py-8">{t('common.noData')}</div>
</div> )}
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex justify-center gap-2 py-3"> <div className="flex justify-center gap-2 py-3">
@@ -218,7 +368,34 @@ export default function DatasetPage() {
)} )}
{tab === 'editor' && editorMedia && editorAnnotation && ( {tab === 'editor' && editorMedia && editorAnnotation && (
<div className="flex-1 overflow-hidden"> <div className="flex-1 min-h-0 relative overflow-hidden">
<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">
<button
onClick={handleEditorSave}
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'}
</button>
<button
onClick={handleEditorCancel}
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'}
</button>
<span className="text-az-muted text-[10px]">
{editorDetections.length} detection{editorDetections.length !== 1 ? 's' : ''}
</span>
{!editorLocalGroupId && (
<span className="text-az-muted text-[10px] ml-auto">
remote save not wired yet
</span>
)}
</div>
<div className="flex-1 min-h-0 relative">
<div className="absolute inset-0">
<CanvasEditor <CanvasEditor
media={editorMedia} media={editorMedia}
annotation={editorAnnotation} annotation={editorAnnotation}
@@ -229,22 +406,28 @@ export default function DatasetPage() {
annotations={[]} annotations={[]}
/> />
</div> </div>
</div>
</div>
</div>
)} )}
{tab === 'distribution' && ( {tab === 'distribution' && (
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto bg-az-bg">
<div className="space-y-1.5 max-w-2xl"> {distribution.map(d => {
{distribution.map(d => ( const pct = (d.count / maxDistCount) * 100
<div key={d.classNum} className="flex items-center gap-2 text-xs"> return (
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: d.color }} /> <div key={d.classNum} className="relative h-6 border-b border-az-border/40">
<span className="w-40 truncate text-az-text">{d.label}</span> <div
<div className="flex-1 bg-az-bg rounded h-4 overflow-hidden"> className="absolute inset-y-0 left-0"
<div className="h-full rounded" style={{ width: `${(d.count / maxDistCount) * 100}%`, backgroundColor: d.color, opacity: 0.7 }} /> style={{ width: `${pct}%`, backgroundColor: d.color, opacity: 0.85 }}
/>
<div className="relative flex items-center justify-between h-full px-2 text-xs text-white tabular-nums">
<span className="truncate">{d.label}: {d.count}</span>
<span className="pl-2">{d.count}</span>
</div> </div>
<span className="text-az-muted w-12 text-right">{d.count}</span>
</div>
))}
</div> </div>
)
})}
</div> </div>
)} )}
</div> </div>
+1 -1
View File
@@ -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"} {"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"}