import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { FaDownload } from 'react-icons/fa' import { api, createSSE, endpoints } from '../../api' import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors' import type { Media, AnnotationListItem, PaginatedResponse } from '../../types' interface Props { media: Media | null annotations: AnnotationListItem[] selectedAnnotation: AnnotationListItem | null onSelect: (ann: AnnotationListItem) => void onAnnotationsUpdate: (anns: AnnotationListItem[]) => void onDownload?: (ann: AnnotationListItem) => void } function getRowGradient(ann: AnnotationListItem): string { if (ann.detections.length === 0) { return 'linear-gradient(90deg, rgba(221,221,221,0.10), rgba(221,221,221,0.04))' } if (ann.detections.length === 1) { const c = getClassColor(ann.detections[0].classNum) return `linear-gradient(90deg, ${hexToRgba(c, 0.55)} 0%, ${hexToRgba(c, 0.10)} 60%, transparent 100%)` } const n = ann.detections.length const bandWidth = 100 / n const stops: string[] = [] ann.detections.forEach((d, i) => { const c = getClassColor(d.classNum) const start = i * bandWidth const mid = start + bandWidth * 0.6 const end = (i + 1) * bandWidth stops.push(`${hexToRgba(c, 0.50)} ${start}%`) stops.push(`${hexToRgba(c, 0.10)} ${mid}%`) if (i < n - 1) stops.push(`${hexToRgba(c, 0.10)} ${end - 0.01}%`) }) return `linear-gradient(90deg, ${stops.join(', ')})` } interface ClassAgg { classNum: number; color: string; count: number } function aggregateClasses(annotations: AnnotationListItem[]): ClassAgg[] { const counts = new Map() for (const ann of annotations) { for (const d of ann.detections) { counts.set(d.classNum, (counts.get(d.classNum) ?? 0) + 1) } } return [...counts.entries()] .map(([classNum, count]) => ({ classNum, color: getClassColor(classNum), count })) .sort((a, b) => b.count - a.count) .slice(0, 6) } export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) { const { t } = useTranslation() useEffect(() => { if (!media) return return createSSE<{ annotationId: string; mediaId: string; status: number }>(endpoints.annotations.annotationEvents(), (event) => { if (event.mediaId === media.id) { api.get>( endpoints.annotations.annotationsByMedia(media.id), ).then(res => onAnnotationsUpdate(res.items)).catch(() => {}) } }) }, [media, onAnnotationsUpdate]) const totals = useMemo(() => ({ total: annotations.length, empty: annotations.filter(a => a.detections.length === 0).length, }), [annotations]) const classDist = useMemo(() => aggregateClasses(annotations), [annotations]) return (
{t('annotations.title')} {String(annotations.length).padStart(2, '0')}
{t('annotations.colTime')} {t('annotations.colClass')} {t('annotations.colConf')}
{annotations.map(ann => { const isSelected = selectedAnnotation?.id === ann.id const isEmpty = ann.detections.length === 0 const first = ann.detections[0] const extra = ann.detections.length > 1 ? ` +${ann.detections.length - 1}` : '' const maxConf = ann.detections.reduce((m, d) => Math.max(m, d.confidence ?? 0), 0) const className = first ? (first.label || getClassNameFallback(first.classNum)) : '' return (
onSelect(ann)} className={`ann-row${isSelected ? ' active' : ''}`} style={{ ['--row-grad' as string]: getRowGradient(ann) }} > {ann.time || '—'} {isEmpty ? {t('annotations.emptyFrame')} : {className}{extra} }
{isSelected && !isEmpty && onDownload && ( )} {isEmpty ? '—' : `${Math.round(maxConf * 100)}%`}
) })} {annotations.length === 0 && (
{t('common.noData')}
)}
{t('annotations.summary')} {t('annotations.annCount', { count: totals.total })} · {t('annotations.emptyCount', { count: totals.empty })}
{classDist.length > 0 && ( <>
{classDist.map(c => ( ))}
{classDist.map(c => ( {c.count} ))}
)}
) }