Spaces:
Running
Running
| 'use client' | |
| import { useState, useMemo } from 'react' | |
| import type { GuardrailData } from '@/lib/types' | |
| import { guardrailCreatorColor } from '@/lib/utils' | |
| import { LabLogo } from '@/components/LabLogo' | |
| type Metric = 'recall' | 'precision' | 'f1' | |
| const TABS: { key: Metric; label: string; yLabel: string }[] = [ | |
| { key: 'recall', label: 'Recall', yLabel: 'Score (Higher is Better)' }, | |
| { key: 'precision', label: 'Precision', yLabel: 'Score (Higher is Better)' }, | |
| { key: 'f1', label: 'F1 Score', yLabel: 'Score (Higher is Better)' }, | |
| ] | |
| const ML = 80 | |
| const MR = 80 | |
| const MT = 14 | |
| const BAR_H = 300 | |
| const LBL_H = 150 | |
| const VW = 1100 | |
| const VH = MT + BAR_H + LBL_H | |
| const CHART_W = VW - ML - MR | |
| const GAP = 4 | |
| const GRID = [0, 0.25, 0.5, 0.75, 1.0] | |
| export default function GuardrailBarChart({ guardrails }: { guardrails: GuardrailData[] }) { | |
| const [metric, setMetric] = useState<Metric>('f1') | |
| const [activeCreators, setCreators] = useState<Set<string>>(new Set()) | |
| const allCreators = useMemo( | |
| () => [...new Set(guardrails.map(g => g.creator))].sort(), | |
| [guardrails], | |
| ) | |
| const toggleCreator = (c: string) => | |
| setCreators(prev => { const s = new Set(prev); s.has(c) ? s.delete(c) : s.add(c); return s }) | |
| const sorted = useMemo(() => { | |
| let list = [...guardrails] | |
| if (activeCreators.size) list = list.filter(g => activeCreators.has(g.creator)) | |
| list = list.filter(g => g[metric] !== null) | |
| return list.sort((a, b) => (b[metric] ?? 0) - (a[metric] ?? 0)) | |
| }, [guardrails, metric, activeCreators]) | |
| const n = sorted.length | |
| const bw = n > 0 ? (CHART_W - (n - 1) * GAP) / n : 0 | |
| const legendCreators = useMemo( | |
| () => [...new Set(sorted.map(g => g.creator))].sort(), | |
| [sorted], | |
| ) | |
| const tab = TABS.find(t => t.key === metric)! | |
| return ( | |
| <div> | |
| {/* Filters row */} | |
| <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}> | |
| {/* Creator chips */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> | |
| <span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase' as const, color: 'var(--text-3)', marginRight: 2 }}>Provider</span> | |
| {allCreators.map(c => { | |
| const cc = guardrailCreatorColor(c) | |
| const on = activeCreators.has(c) | |
| return ( | |
| <button key={c} onClick={() => toggleCreator(c)} style={{ | |
| height: 30, padding: '0 10px', | |
| border: `1.5px solid ${on ? cc : 'var(--border-1)'}`, | |
| borderRadius: 6, fontSize: 11, fontFamily: 'inherit', fontWeight: 700, | |
| color: on ? cc : 'var(--text-2)', | |
| background: on ? `${cc}12` : 'var(--bg-1)', | |
| cursor: 'pointer', whiteSpace: 'nowrap' as const, | |
| display: 'inline-flex', alignItems: 'center', gap: 5, | |
| }}> | |
| <span style={{ display: 'inline-flex', alignItems: 'center', lineHeight: 0 }}> | |
| <LabLogo creator={c} /> | |
| </span> | |
| {c} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| {/* Metric tabs */} | |
| <div style={{ display: 'inline-flex', border: '1.5px solid var(--border-1)', borderRadius: 7, overflow: 'hidden', flexShrink: 0 }}> | |
| {TABS.map((t, i) => ( | |
| <button key={t.key} onClick={() => setMetric(t.key)} style={{ | |
| height: 30, padding: '0 13px', | |
| borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none', | |
| outline: 'none', border: i > 0 ? '1px solid var(--border-1)' : 'none', | |
| borderTop: 'none', borderRight: 'none', borderBottom: 'none', | |
| fontSize: 10, fontFamily: 'inherit', fontWeight: 700, | |
| letterSpacing: '0.05em', textTransform: 'uppercase' as const, | |
| color: metric === t.key ? 'white' : 'var(--text-2)', | |
| background: metric === t.key ? 'var(--accent)' : 'transparent', | |
| cursor: 'pointer', whiteSpace: 'nowrap' as const, | |
| transition: 'background 0.12s, color 0.12s', | |
| }}> | |
| {t.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Chart */} | |
| <div style={{ | |
| background: 'var(--bg-1)', | |
| border: '1.5px solid var(--border-1)', | |
| borderRadius: 10, | |
| padding: '1rem 1rem 0', | |
| boxShadow: '0 1px 4px rgba(0,0,0,0.05)', | |
| }}> | |
| <svg | |
| viewBox={`0 0 ${VW} ${VH}`} | |
| style={{ width: '100%', height: 'auto', display: 'block' }} | |
| aria-label={`Bar chart: ${tab.label}`} | |
| > | |
| {/* Grid lines + y-axis labels */} | |
| {GRID.map(lvl => { | |
| const y = MT + BAR_H - lvl * BAR_H | |
| return ( | |
| <g key={lvl}> | |
| <line | |
| x1={ML} y1={y} x2={VW - MR} y2={y} | |
| stroke={lvl === 0 ? 'var(--border-2)' : 'var(--border-0)'} | |
| strokeWidth={lvl === 0 ? 1 : 0.8} | |
| strokeDasharray={lvl === 0 || lvl === 1 ? undefined : '4 4'} | |
| /> | |
| <text | |
| x={ML - 5} y={y + 3.5} | |
| textAnchor="end" | |
| fontSize={9} | |
| fill="var(--text-3)" | |
| fontFamily="inherit" | |
| > | |
| {`${Math.round(lvl * 100)}`} | |
| </text> | |
| </g> | |
| ) | |
| })} | |
| {/* Y-axis title */} | |
| <text | |
| x={11} | |
| y={MT + BAR_H / 2} | |
| textAnchor="middle" | |
| fontSize={8.5} | |
| fill="var(--text-3)" | |
| fontFamily="inherit" | |
| transform={`rotate(-90, 11, ${MT + BAR_H / 2})`} | |
| > | |
| {tab.yLabel} | |
| </text> | |
| {/* Bars */} | |
| {sorted.map((g, i) => { | |
| const val = g[metric]! | |
| const barHeight = val * BAR_H | |
| const x = ML + i * (bw + GAP) | |
| const y = MT + BAR_H - barHeight | |
| const cc = guardrailCreatorColor(g.creator) | |
| return ( | |
| <g key={`${g.rank}-${metric}`}> | |
| <rect | |
| x={x} y={y} | |
| width={bw} height={barHeight} | |
| fill={cc} fillOpacity={0.88} | |
| rx={2} | |
| /> | |
| {barHeight > 18 && ( | |
| <text | |
| x={x + bw / 2} y={y - 4} | |
| textAnchor="middle" | |
| fontSize={8} | |
| fill={cc} | |
| fontFamily="inherit" | |
| fontWeight="bold" | |
| > | |
| {`${(val * 100).toFixed(0)}%`} | |
| </text> | |
| )} | |
| <text | |
| x={x + bw / 2} | |
| y={MT + BAR_H + 5} | |
| transform={`rotate(-45, ${x + bw / 2}, ${MT + BAR_H + 5})`} | |
| textAnchor="end" | |
| fontSize={10} | |
| fill="var(--text-1)" | |
| fontFamily="inherit" | |
| fontWeight="600" | |
| > | |
| {g.guardrail} | |
| </text> | |
| </g> | |
| ) | |
| })} | |
| </svg> | |
| {/* Legend */} | |
| <div style={{ | |
| display: 'flex', | |
| flexWrap: 'wrap', | |
| gap: '8px 20px', | |
| justifyContent: 'center', | |
| padding: '12px 8px', | |
| borderTop: '1px solid var(--border-0)', | |
| marginTop: 4, | |
| }}> | |
| {legendCreators.map(c => ( | |
| <div key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)', fontWeight: 600 }}> | |
| <div style={{ width: 11, height: 11, borderRadius: 2, background: guardrailCreatorColor(c), flexShrink: 0 }} /> | |
| {c} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |