/** * Visual Query Builder — an alternate, direct-manipulation front end for the * metric-cube query API (/api/evaluations/query). * * Drag dimension chips from the tray into the "Group by" and "Filter" wells, * pick filter values with visual chips, toggle metrics, and watch the live * result preview (table + bars) update as you build. The generated request * JSON is always visible, so the visual query stays auditable against the * manual API. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Chip from '@mui/material/Chip'; import Typography from '@mui/material/Typography'; import { GripVertical, X } from 'lucide-react'; import { DIM_NAMES, type DimName, type EvaluationQueryResponse, type MetricName } from '../../types/megaeval'; import { getDimensions, queryEvaluations } from '../../services/megaeval'; import { SectionCard, HBar, fmtPct, fmtInt } from '../../components/labeleval/shared'; import { InfoDot } from '../help/InfoTip'; import { PanelSkeleton, IllustratedEmpty } from '../visual/Feedback'; import DragList from '../visual/DragList'; import { tokens } from '../../theme'; const DIM_COLORS: Record = { class: '#66bb6a', weather: '#4fc3f7', lighting: '#f9a825', road_type: '#ab47bc', scenario: '#ff7043', sensor: '#26c6da', distance_band: '#9ccc65', speed_band: '#ec407a', occlusion: '#8a949e', }; const METRIC_CHOICES: Array<{ name: MetricName; label: string; help: string; pct: boolean }> = [ { name: 'recall', label: 'recall', help: 'TP / (TP+FN) — fraction of real objects labeled.', pct: true }, { name: 'precision', label: 'precision', help: 'TP / (TP+FP) — fraction of produced labels that are correct.', pct: true }, { name: 'f1', label: 'f1', help: 'Harmonic mean of precision and recall.', pct: true }, { name: 'mean_iou', label: 'mean IoU', help: 'Mean 3D overlap of matched boxes.', pct: true }, { name: 'safety_recall', label: 'safety recall', help: 'Recall restricted to safety-critical objects.', pct: true }, { name: 'error_rate', label: 'error rate', help: '(FP+FN) / n.', pct: true }, { name: 'anomaly_rate', label: 'anomaly rate', help: 'Fraction of objects flagged by the anomaly ensemble.', pct: true }, { name: 'conf_mean', label: 'conf mean', help: 'Mean detection confidence.', pct: false }, ]; type Well = 'group' | 'filter'; interface FilterClause { dim: DimName; values: string[]; } interface VisualQueryTabProps { runId: string; } export default function VisualQueryTab({ runId }: VisualQueryTabProps) { const [dimValues, setDimValues] = useState | null>(null); const [groupBy, setGroupBy] = useState(['class']); const [filters, setFilters] = useState([]); const [metrics, setMetrics] = useState(['recall', 'precision']); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [dragOverWell, setDragOverWell] = useState(null); const debounceRef = useRef | null>(null); useEffect(() => { getDimensions() .then((d) => setDimValues(d.dimensions as Record)) .catch((e) => setError(e instanceof Error ? e.message : String(e))); }, []); const request = useMemo( () => ({ evaluation_id: runId, group_by: groupBy, filters: Object.fromEntries(filters.filter((f) => f.values.length).map((f) => [f.dim, f.values])), metrics, limit: 40, }), [runId, groupBy, filters, metrics] ); // live preview: debounce 350 ms after any change useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); setLoading(true); debounceRef.current = setTimeout(() => { queryEvaluations(request) .then((r) => { setResult(r); setError(null); }) .catch((e) => setError(e instanceof Error ? e.message : String(e))) .finally(() => setLoading(false)); }, 350); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [request]); const dropDim = useCallback( (well: Well, dim: DimName) => { if (well === 'group') { setGroupBy((g) => (g.includes(dim) ? g : [...g, dim])); } else { setFilters((fs) => (fs.some((f) => f.dim === dim) ? fs : [...fs, { dim, values: [] }])); } setDragOverWell(null); }, [] ); const wellProps = (well: Well) => ({ onDragOver: (e: React.DragEvent) => { e.preventDefault(); setDragOverWell(well); }, onDragLeave: () => setDragOverWell(null), onDrop: (e: React.DragEvent) => { e.preventDefault(); const dim = e.dataTransfer.getData('application/x-dim') as DimName; if (dim && DIM_NAMES.includes(dim)) dropDim(well, dim); }, }); const firstMetric = metrics[0]; const firstMetricMeta = METRIC_CHOICES.find((m) => m.name === firstMetric); const rows = result?.rows ?? []; const barMax = useMemo(() => { if (!firstMetric) return 1; const vals = rows.map((r) => (typeof r[firstMetric] === 'number' ? (r[firstMetric] as number) : 0)); return firstMetricMeta?.pct ? 1 : Math.max(1e-9, ...vals); }, [rows, firstMetric, firstMetricMeta]); const rowLabel = (r: Record): string => groupBy.map((d) => String(r[d] ?? '—')).join(' · ') || 'all'; return ( Build the query visually } > {/* dimension tray */} Dimensions — drag into a well: {DIM_NAMES.map((d) => ( } label={d.replace(/_/g, ' ')} draggable onDragStart={(e) => { e.dataTransfer.setData('application/x-dim', d); e.dataTransfer.effectAllowed = 'copy'; }} onClick={() => dropDim('group', d)} sx={{ cursor: 'grab', bgcolor: `${DIM_COLORS[d]}1a`, color: DIM_COLORS[d], border: `1px solid ${DIM_COLORS[d]}55`, fontWeight: 700, '&:active': { cursor: 'grabbing' }, }} /> ))} {/* group-by well */} GROUP BY {groupBy.length ? `(${groupBy.length} — drag the handle to reorder)` : '— drop a dimension here'} ({ id: d, content: ( setGroupBy((g) => g.filter((x) => x !== d))} sx={{ display: 'inline-flex', cursor: 'pointer', color: tokens.color.textFaint, '&:hover': { color: tokens.color.danger } }} title="Remove from group-by" > ), }))} onReorder={(ids) => setGroupBy(ids as DimName[])} /> {/* filter well */} FILTERS {filters.length ? `(${filters.length})` : '— drop a dimension here, then pick values'} {filters.map((f) => ( is any of {(dimValues?.[f.dim] ?? []).map((v) => { const on = f.values.includes(v); return ( setFilters((fs) => fs.map((x) => (x.dim === f.dim ? { ...x, values: on ? x.values.filter((y) => y !== v) : [...x.values, v] } : x)) ) } sx={{ height: 21, fontSize: 10.5, cursor: 'pointer', bgcolor: on ? tokens.color.infoBg : 'transparent', color: on ? tokens.color.info : tokens.color.textFaint, border: `1px solid ${on ? tokens.color.info : tokens.color.border}`, transition: `all ${tokens.motion.fast}`, }} /> ); })} setFilters((fs) => fs.filter((x) => x.dim !== f.dim))} sx={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer', color: tokens.color.textFaint, '&:hover': { color: tokens.color.danger } }} title="Remove this filter" > ))} {/* metric toggles */} Metrics: {METRIC_CHOICES.map((m) => { const on = metrics.includes(m.name); return ( setMetrics((ms) => (on ? ms.filter((x) => x !== m.name) : [...ms, m.name]))} sx={{ cursor: 'pointer', bgcolor: on ? tokens.color.successBg : 'transparent', color: on ? tokens.color.success : tokens.color.textFaint, border: `1px solid ${on ? tokens.color.success : tokens.color.border}`, fontWeight: on ? 700 : 400, transition: `all ${tokens.motion.fast}`, }} /> ); })} ) : undefined } > {error ? ( {error} ) : null} {loading && !result ? : null} {result && rows.length === 0 ? ( ) : null} {rows.length > 0 ? ( {firstMetric ? ( {firstMetricMeta?.label ?? firstMetric} by {groupBy.map((d) => d.replace(/_/g, ' ')).join(' × ') || 'population'} {rows.slice(0, 14).map((r, i) => { const v = typeof r[firstMetric] === 'number' ? (r[firstMetric] as number) : 0; return ( ); })} ) : null} group n {metrics.map((m) => ( {m.replace(/_/g, ' ')} ))} {rows.slice(0, 20).map((r, i) => ( {rowLabel(r)} {fmtInt(r.n)} {metrics.map((m) => { const v = r[m]; const meta = METRIC_CHOICES.find((x) => x.name === m); return ( {typeof v === 'number' ? (meta?.pct ? fmtPct(v) : v.toFixed(3)) : '—'} ); })} ))} ) : null} {JSON.stringify(request, null, 2)} ); }