Spaces:
Paused
Paused
File size: 7,286 Bytes
729628a fb23187 729628a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | import { useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import type { HaystackCategory, HaystackPoint } from '../../types/labeleval';
import { SectionCard, fmtInt } from './shared';
const WIDTH = 700;
const HEIGHT = 420;
const PAD = 24;
const CATEGORY_STYLE: Record<HaystackCategory, { color: string; label: string }> = {
normal: { color: '#5c6773', label: 'Normal' },
anomaly: { color: '#ffa726', label: 'Anomaly' },
rare_event: { color: '#ef5350', label: 'Rare event' },
false_positive: { color: '#ab47bc', label: 'False positive' },
false_negative: { color: '#42a5f5', label: 'False negative' },
verified: { color: '#66bb6a', label: 'Verified' },
};
const CATEGORY_ORDER: HaystackCategory[] = [
'normal',
'verified',
'false_positive',
'false_negative',
'anomaly',
'rare_event',
];
export default function HaystackVisualizer({
points,
onPointClick,
}: {
points: HaystackPoint[];
onPointClick: (point: HaystackPoint) => void;
}) {
const [hovered, setHovered] = useState<HaystackPoint | null>(null);
const { scaled, counts } = useMemo(() => {
const counts = new Map<HaystackCategory, number>();
for (const p of points) counts.set(p.category, (counts.get(p.category) ?? 0) + 1);
if (points.length === 0) return { scaled: [] as Array<HaystackPoint & { sx: number; sy: number }>, counts };
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const p of points) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
const spanX = maxX - minX || 1;
const spanY = maxY - minY || 1;
const scaled = points.map((p) => ({
...p,
sx: PAD + ((p.x - minX) / spanX) * (WIDTH - 2 * PAD),
sy: HEIGHT - PAD - ((p.y - minY) / spanY) * (HEIGHT - 2 * PAD),
}));
// Draw interesting categories on top of the normal haystack.
scaled.sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category));
return { scaled, counts };
}, [points]);
return (
<SectionCard
title="Haystack — finding the needles"
help="2D feature-space projection of every evaluated label. Gray points are the nominal mass; colored points are the interesting minority (anomalies, rare events, FP/FN, verified). Clusters of colored points suggest a systematic blind spot rather than one-off noise. Click any point to open its evidence."
>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Box sx={{ position: 'relative' }}>
<svg
width={WIDTH}
height={HEIGHT}
style={{ background: '#0c1014', border: '1px solid #232a31', borderRadius: 6, maxWidth: '100%' }}
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
>
{/* subtle grid */}
{Array.from({ length: 7 }, (_, i) => (
<line
key={`v${i}`}
x1={PAD + (i * (WIDTH - 2 * PAD)) / 6}
y1={PAD}
x2={PAD + (i * (WIDTH - 2 * PAD)) / 6}
y2={HEIGHT - PAD}
stroke="#1a2027"
/>
))}
{Array.from({ length: 5 }, (_, i) => (
<line
key={`h${i}`}
x1={PAD}
y1={PAD + (i * (HEIGHT - 2 * PAD)) / 4}
x2={WIDTH - PAD}
y2={PAD + (i * (HEIGHT - 2 * PAD)) / 4}
stroke="#1a2027"
/>
))}
{scaled.map((p) => {
const style = CATEGORY_STYLE[p.category];
const interesting = p.category !== 'normal';
const common = {
cursor: 'pointer',
onClick: () => onPointClick(p),
onMouseEnter: () => setHovered(p),
onMouseLeave: () => setHovered(null),
};
if (p.category === 'rare_event') {
const r = 6;
return (
<polygon
key={p.id}
points={`${p.sx},${p.sy - r} ${p.sx + r},${p.sy} ${p.sx},${p.sy + r} ${p.sx - r},${p.sy}`}
fill={style.color}
stroke="#fff"
strokeWidth={0.8}
{...common}
/>
);
}
return (
<circle
key={p.id}
cx={p.sx}
cy={p.sy}
r={interesting ? 4.5 : 2}
fill={style.color}
fillOpacity={interesting ? 0.95 : 0.55}
stroke={interesting ? '#101418' : 'none'}
strokeWidth={interesting ? 0.6 : 0}
{...common}
/>
);
})}
{points.length === 0 ? (
<text x={WIDTH / 2} y={HEIGHT / 2} textAnchor="middle" fill="#8a949e" fontSize={13}>
No haystack points yet — run the evaluation pipeline.
</text>
) : null}
</svg>
{hovered ? (
<Box
sx={{
position: 'absolute',
left: 8,
top: 8,
bgcolor: 'rgba(16,20,24,0.92)',
border: '1px solid #2c353e',
borderRadius: 1,
px: 1,
py: 0.5,
pointerEvents: 'none',
}}
>
<Typography variant="caption" sx={{ display: 'block', fontFamily: 'monospace' }}>
{hovered.id}
</Typography>
<Typography variant="caption" sx={{ color: '#8a949e' }}>
{hovered.class_name} · {CATEGORY_STYLE[hovered.category].label} · score{' '}
{hovered.anomaly_score.toFixed(3)} · {hovered.frame_id}
</Typography>
</Box>
) : null}
</Box>
<Box sx={{ minWidth: 170 }}>
<Typography variant="caption" sx={{ color: '#8a949e', fontWeight: 700 }}>
LEGEND
</Typography>
{CATEGORY_ORDER.map((cat) => {
const style = CATEGORY_STYLE[cat];
return (
<Box key={cat} sx={{ display: 'flex', alignItems: 'center', gap: 1, my: 0.75 }}>
{cat === 'rare_event' ? (
<Box sx={{ width: 10, height: 10, bgcolor: style.color, transform: 'rotate(45deg)' }} />
) : (
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: style.color }} />
)}
<Typography variant="body2" sx={{ flex: 1 }}>
{style.label}
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{fmtInt(counts.get(cat) ?? 0)}
</Typography>
</Box>
);
})}
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mt: 1 }}>
Click any point to open its full evidence panel.
</Typography>
</Box>
</Box>
</SectionCard>
);
}
|