mirror of
https://github.com/azaion/ui.git
synced 2026-06-24 17:41:11 +00:00
085d7bf17e
Co-authored-by: Cursor <cursoragent@cursor.com>
520 lines
18 KiB
TypeScript
520 lines
18 KiB
TypeScript
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 (
|
|
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
|
|
<polygon points="5.5,0.7 10.3,5.5 5.5,10.3 0.7,5.5" fill="#FF0000" stroke="#0A0D10" strokeWidth="1"/>
|
|
</svg>
|
|
)
|
|
}
|
|
if (FRIENDLY_HEXES.has(up)) {
|
|
return (
|
|
<svg width="11" height="9" viewBox="0 0 11 9" aria-hidden="true">
|
|
<rect x="0.5" y="0.5" width="10" height="8" fill="#87CEEB" stroke="#0A0D10" strokeWidth="1"/>
|
|
</svg>
|
|
)
|
|
}
|
|
return (
|
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
|
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.2"/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
|
|
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations, onZoomChange, onCursorChange },
|
|
ref,
|
|
) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const imgRef = useRef<HTMLImageElement | null>(null)
|
|
const cursorRafRef = useRef<number | null>(null)
|
|
const cursorLatestRef = useRef<{ x: number; y: number } | null>(null)
|
|
const [zoom, setZoom] = useState(1)
|
|
const [pan, setPan] = useState({ x: 0, y: 0 })
|
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
|
|
const [imgSize, setImgSize] = useState({ w: 0, h: 0 })
|
|
const [labelChips, setLabelChips] = useState<LabelChip[]>([])
|
|
|
|
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 (
|
|
<div ref={containerRef} className="w-full h-full flex-1 relative overflow-hidden cursor-crosshair">
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="absolute inset-0"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
onWheel={handleWheel}
|
|
/>
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
{labelChips.map((chip, i) => (
|
|
<div
|
|
key={i}
|
|
className="bbox-label"
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${chip.leftPct}%`,
|
|
top: `calc(${chip.topPct}% - 26px)`,
|
|
borderColor: hexToRgba(chip.color, 0.6),
|
|
}}
|
|
>
|
|
<span style={{ color: chip.color, display: 'inline-flex' }}>{affiliationIcon(chip.color)}</span>
|
|
{chip.combatReady && <span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--accent-green)', display: 'inline-block' }} />}
|
|
<span style={{ color: chip.color }}>{chip.name}</span>
|
|
{chip.conf < 0.995 && <span className="conf">{(chip.conf * 100).toFixed(1)}%</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
export default CanvasEditor
|