mirror of
https://github.com/azaion/ui.git
synced 2026-04-25 14:06:34 +00:00
feat(dataset): per-detection cards, in-browser editor, bulk-validate for local saves
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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<VideoPlayerHandle>(null)
|
||||
const canvasRef = useRef<CanvasEditorHandle>(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
|
||||
|
||||
@@ -75,16 +75,29 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(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])
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<DatasetItem[]>([])
|
||||
@@ -53,21 +72,131 @@ export default function DatasetPage() {
|
||||
|
||||
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 {
|
||||
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${item.annotationId}`)
|
||||
const ann = await api.get<AnnotationListItem>(`/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
|
||||
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: Array.from(selectedIds),
|
||||
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() {
|
||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
||||
|
||||
{/* 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 */}
|
||||
<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" />
|
||||
@@ -163,49 +292,70 @@ export default function DatasetPage() {
|
||||
{tab === 'annotations' && (
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<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
|
||||
key={item.annotationId}
|
||||
key={card.annotationId}
|
||||
onClick={e => {
|
||||
if (e.ctrlKey) {
|
||||
setSelectedIds(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
|
||||
})
|
||||
} else {
|
||||
setSelectedIds(new Set([item.annotationId]))
|
||||
setSelectedIds(new Set([card.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' : ''}`}
|
||||
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 ? (
|
||||
<img
|
||||
src={`/api/annotations/annotations/${item.annotationId}/thumbnail`}
|
||||
alt={item.imageName}
|
||||
className="w-full h-32 object-cover bg-az-bg"
|
||||
src={card.thumbnailUrl}
|
||||
alt={card.imageName}
|
||||
className="w-full h-full object-cover bg-az-bg"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="p-1.5 text-xs">
|
||||
<div className="truncate text-az-text">{item.imageName}</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-az-muted">{new Date(item.createdDate).toLocaleDateString()}</span>
|
||||
<span className={`px-1 rounded ${
|
||||
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')}
|
||||
) : (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
{cards.length === 0 && (
|
||||
<div className="text-center text-az-muted text-xs py-8">{t('common.noData')}</div>
|
||||
)}
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 py-3">
|
||||
@@ -218,7 +368,34 @@ export default function DatasetPage() {
|
||||
)}
|
||||
|
||||
{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
|
||||
media={editorMedia}
|
||||
annotation={editorAnnotation}
|
||||
@@ -229,22 +406,28 @@ export default function DatasetPage() {
|
||||
annotations={[]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'distribution' && (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-1.5 max-w-2xl">
|
||||
{distribution.map(d => (
|
||||
<div key={d.classNum} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
|
||||
<span className="w-40 truncate text-az-text">{d.label}</span>
|
||||
<div className="flex-1 bg-az-bg rounded h-4 overflow-hidden">
|
||||
<div className="h-full rounded" style={{ width: `${(d.count / maxDistCount) * 100}%`, backgroundColor: d.color, opacity: 0.7 }} />
|
||||
<div className="flex-1 overflow-y-auto bg-az-bg">
|
||||
{distribution.map(d => {
|
||||
const pct = (d.count / maxDistCount) * 100
|
||||
return (
|
||||
<div key={d.classNum} className="relative h-6 border-b border-az-border/40">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0"
|
||||
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>
|
||||
<span className="text-az-muted w-12 text-right">{d.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user