import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react' import { endpoints, authenticatedApiUrl } from '../../api' import { MediaType } from '../../types' import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types' import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors' import { parseAnnotationTime } from './time' interface Props { media: Media annotation: AnnotationListItem | null detections: Detection[] onDetectionsChange: (dets: Detection[]) => void selectedClassNum: number currentTime: number annotations: AnnotationListItem[] onZoomChange?: (zoom: number) => void onCursorChange?: (nx: number, ny: number) => void } export interface CanvasEditorHandle { deleteSelected: () => void deleteAll: () => void hasSelection: () => boolean } interface DragState { type: 'draw' | 'move' | 'resize' startX: number startY: number detectionIndex?: number handle?: string } interface LabelChip { leftPct: number topPct: number color: string name: string conf: number combatReady: boolean } const HANDLE_SIZE = 6 const MIN_BOX_SIZE = 12 const HOSTILE_HEXES = new Set(['#FF0000', '#FFFF00', '#FF00FF', '#800000', '#808000', '#800080']) const FRIENDLY_HEXES = new Set(['#00FF00', '#0000FF', '#00FFFF', '#008000', '#000080', '#008080']) function affiliationIcon(hex: string) { const up = hex.toUpperCase() if (HOSTILE_HEXES.has(up)) { return ( ) } if (FRIENDLY_HEXES.has(up)) { return ( ) } return ( ) } const CanvasEditor = forwardRef(function CanvasEditor( { media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations, onZoomChange, onCursorChange }, ref, ) { const canvasRef = useRef(null) const containerRef = useRef(null) const imgRef = useRef(null) const cursorRafRef = useRef(null) const cursorLatestRef = useRef<{ x: number; y: number } | null>(null) const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) const [selected, setSelected] = useState>(new Set()) const [dragState, setDragState] = useState(null) const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null) const [imgSize, setImgSize] = useState({ w: 0, h: 0 }) const [labelChips, setLabelChips] = useState([]) useImperativeHandle(ref, () => ({ deleteSelected() { if (selected.size === 0) return onDetectionsChange(detections.filter((_, i) => !selected.has(i))) setSelected(new Set()) }, deleteAll() { onDetectionsChange([]) setSelected(new Set()) }, hasSelection() { return selected.size > 0 }, }), [selected, detections, onDetectionsChange]) const isVideo = media.mediaType === MediaType.Video const loadImage = useCallback(() => { if (isVideo) { imgRef.current = null return } const img = new Image() img.crossOrigin = 'anonymous' const isLocalPath = media.path.startsWith('blob:') || media.path.startsWith('data:') if (annotation && !isLocalPath) { img.src = authenticatedApiUrl(endpoints.annotations.annotationImage(annotation.id)) } else if (isLocalPath) { img.src = media.path } else { img.src = authenticatedApiUrl(endpoints.annotations.mediaFile(media.id)) } img.onload = () => { imgRef.current = img 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]) useEffect(() => { loadImage() }, [loadImage]) useEffect(() => { if (!isVideo || !containerRef.current) return const update = () => { const c = containerRef.current if (c) setImgSize({ w: c.clientWidth, h: c.clientHeight }) } update() const ro = new ResizeObserver(update) ro.observe(containerRef.current) return () => ro.disconnect() }, [isVideo]) useEffect(() => { onZoomChange?.(zoom) }, [zoom, onZoomChange]) // Cancel any pending cursor RAF on unmount so the callback can't fire after. useEffect(() => () => { if (cursorRafRef.current != null) { cancelAnimationFrame(cursorRafRef.current) cursorRafRef.current = null } }, []) const fromCanvas = useCallback((cx: number, cy: number) => ({ 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))), }), [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 canvas = canvasRef.current const ctx = canvas?.getContext('2d') if (!canvas || !ctx) return if (!isVideo && !imgRef.current) return const container = containerRef.current if (container) { canvas.width = container.clientWidth canvas.height = container.clientHeight } ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.save() if (!isVideo && imgRef.current) { ctx.drawImage(imgRef.current, pan.x, pan.y, imgSize.w * zoom, imgSize.h * zoom) } const timeWindowDets = getTimeWindowDetections() const allDets = [...detections, ...timeWindowDets] const chips: LabelChip[] = [] allDets.forEach((det, i) => { const isOwn = i < detections.length const isSelected = selected.has(i) && isOwn 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 w = det.width * imgSize.w * zoom const h = det.height * imgSize.h * zoom const color = getClassColor(det.classNum) ctx.strokeStyle = color ctx.lineWidth = isSelected ? 2 : 1 ctx.strokeRect(cx, cy, w, h) ctx.fillStyle = color ctx.globalAlpha = 0.06 ctx.fillRect(cx, cy, w, h) ctx.globalAlpha = 1 // Corner brackets — 8px legs (skipped in environments lacking path API, e.g. JSDOM) if (typeof ctx.moveTo === 'function' && typeof ctx.beginPath === 'function') { const legLen = 8 ctx.lineWidth = 2 ctx.beginPath() ctx.moveTo(cx, cy + legLen); ctx.lineTo(cx, cy); ctx.lineTo(cx + legLen, cy) 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) { const handles = getHandles(cx, cy, w, h) handles.forEach(hp => { ctx.fillStyle = '#FF9D3D' ctx.fillRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE) ctx.strokeStyle = '#0A0D10' ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE) }) } }) if (drawRect) { ctx.strokeStyle = '#FF9D3D' ctx.lineWidth = 1 ctx.setLineDash([4, 4]) ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h) ctx.setLineDash([]) } ctx.restore() // 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(() => { const id = requestAnimationFrame(draw) return () => cancelAnimationFrame(id) }, [draw]) useEffect(() => { const container = containerRef.current if (!container) return const obs = new ResizeObserver(() => draw()) obs.observe(container) return () => obs.disconnect() }, [draw]) const hitTest = (cx: number, cy: number) => { for (let i = detections.length - 1; i >= 0; i--) { const d = detections[i] const bx = (d.centerX - d.width / 2) * imgSize.w * zoom + pan.x const by = (d.centerY - d.height / 2) * imgSize.h * zoom + pan.y const bw = d.width * imgSize.w * zoom const bh = d.height * imgSize.h * zoom if (selected.has(i)) { const handles = getHandles(bx, by, bw, bh) for (const h of handles) { if (Math.abs(cx - h.x) < HANDLE_SIZE && Math.abs(cy - h.y) < HANDLE_SIZE) { return { type: 'handle' as const, index: i, handle: h.name } } } } if (cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh) { return { type: 'box' as const, index: i } } } return null } const handleMouseDown = (e: React.MouseEvent) => { const rect = canvasRef.current?.getBoundingClientRect() if (!rect) return const mx = e.clientX - rect.left const my = e.clientY - rect.top if (e.ctrlKey && e.button === 0) { setDragState({ type: 'draw', startX: mx, startY: my }) return } const hit = hitTest(mx, my) if (hit?.type === 'handle') { setDragState({ type: 'resize', startX: mx, startY: my, detectionIndex: hit.index, handle: hit.handle }) } else if (hit?.type === 'box') { if (e.ctrlKey) { setSelected(prev => { const n = new Set(prev); n.has(hit.index) ? n.delete(hit.index) : n.add(hit.index); return n }) } else { setSelected(new Set([hit.index])) } setDragState({ type: 'move', startX: mx, startY: my, detectionIndex: hit.index }) } else { setSelected(new Set()) setDragState({ type: 'draw', startX: mx, startY: my }) } } const handleMouseMove = (e: React.MouseEvent) => { const rect = canvasRef.current?.getBoundingClientRect() if (!rect) return const mx = e.clientX - rect.left 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') { setDrawRect({ x: Math.min(dragState.startX, mx), y: Math.min(dragState.startY, my), w: Math.abs(mx - dragState.startX), h: Math.abs(my - dragState.startY), }) } else if (dragState.type === 'move' && dragState.detectionIndex !== undefined) { const dx = (mx - dragState.startX) / (imgSize.w * zoom) const dy = (my - dragState.startY) / (imgSize.h * zoom) const newDets = [...detections] const indices = selected.size > 0 ? Array.from(selected) : [dragState.detectionIndex] indices.forEach(i => { if (newDets[i]) { newDets[i] = { ...newDets[i], centerX: Math.max(newDets[i].width / 2, Math.min(1 - newDets[i].width / 2, newDets[i].centerX + dx)), centerY: Math.max(newDets[i].height / 2, Math.min(1 - newDets[i].height / 2, newDets[i].centerY + dy)), } } }) onDetectionsChange(newDets) setDragState({ ...dragState, startX: mx, startY: my }) } else if (dragState.type === 'resize' && dragState.detectionIndex !== undefined && dragState.handle) { const idx = dragState.detectionIndex const d = detections[idx] const norm = fromCanvas(mx, my) const newDets = [...detections] let x1 = d.centerX - d.width / 2, y1 = d.centerY - d.height / 2 let x2 = d.centerX + d.width / 2, y2 = d.centerY + d.height / 2 if (dragState.handle.includes('l')) x1 = norm.x if (dragState.handle.includes('r')) x2 = norm.x if (dragState.handle.includes('t')) y1 = norm.y if (dragState.handle.includes('b')) y2 = norm.y const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1) if (w * imgSize.w * zoom >= MIN_BOX_SIZE && h * imgSize.h * zoom >= MIN_BOX_SIZE) { newDets[idx] = { ...d, centerX: Math.min(x1, x2) + w / 2, centerY: Math.min(y1, y2) + h / 2, width: w, height: h, } onDetectionsChange(newDets) } } } const handleMouseUp = () => { if (dragState?.type === 'draw' && drawRect) { const w = drawRect.w / (imgSize.w * zoom) const h = drawRect.h / (imgSize.h * zoom) if (w * imgSize.w >= MIN_BOX_SIZE && h * imgSize.h >= MIN_BOX_SIZE) { const center = fromCanvas(drawRect.x + drawRect.w / 2, drawRect.y + drawRect.h / 2) const newDet: Detection = { id: crypto.randomUUID(), classNum: selectedClassNum, label: '', confidence: 1, affiliation: 0 as Affiliation, combatReadiness: 0 as CombatReadiness, centerX: center.x, centerY: center.y, width: w, height: h, } onDetectionsChange([...detections, newDet]) setSelected(new Set([detections.length])) } setDrawRect(null) } setDragState(null) } const handleWheel = (e: React.WheelEvent) => { if (!e.ctrlKey) return e.preventDefault() const delta = e.deltaY > 0 ? 0.9 : 1.1 setZoom(z => Math.max(0.1, Math.min(10, z * delta))) } useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement) return if (e.key === 'Delete' && selected.size > 0) { onDetectionsChange(detections.filter((_, i) => !selected.has(i))) setSelected(new Set()) } if (e.key === 'x' || e.key === 'X') { if (e.target instanceof HTMLInputElement) return onDetectionsChange([]) setSelected(new Set()) } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [detections, selected, onDetectionsChange]) return (
{labelChips.map((chip, i) => (
{affiliationIcon(chip.color)} {chip.combatReady && } {chip.name} {chip.conf < 0.995 && {(chip.conf * 100).toFixed(1)}%}
))}
) }) export default CanvasEditor