Add Dots/Bars toggle to 'Where models cluster' card

#2
src/components/BarChartSection.tsx DELETED
@@ -1,252 +0,0 @@
1
- 'use client'
2
-
3
- import { useState, useMemo } from 'react'
4
- import type { ModelData } from '@/lib/types'
5
- import { fmtPct, fmtNum, creatorColor } from '@/lib/utils'
6
- import { LabLogo } from '@/components/LabLogo'
7
-
8
- type Metric = 'luc' | 'rag' | 'fairness'
9
-
10
- const TABS: { key: Metric; label: string; yLabel: string }[] = [
11
- { key: 'luc', label: 'Refusal Rate', yLabel: 'Score (Higher is Better)' },
12
- { key: 'rag', label: 'RAG Score', yLabel: 'Score (Higher is Better)' },
13
- { key: 'fairness', label: 'Fairness', yLabel: 'Wasserstein Distance (Lower is Better)' },
14
- ]
15
-
16
- function getValue(m: ModelData, metric: Metric): number | null {
17
- if (metric === 'luc') return m.luc.avg
18
- if (metric === 'rag') return m.rag.avg
19
- return m.fairness.avg
20
- }
21
-
22
- // SVG layout (coordinate units)
23
- const ML = 80 // left margin
24
- const MR = 80 // right margin
25
- const MT = 14 // top margin
26
- const BAR_H = 300 // bar area height
27
- const LBL_H = 150 // rotated label area
28
- const VW = 1100
29
- const VH = MT + BAR_H + LBL_H
30
- const CHART_W = VW - ML - MR
31
- const GAP = 4
32
-
33
- const GRID = [0, 0.25, 0.5, 0.75, 1.0]
34
-
35
- export default function BarChartSection({
36
- models,
37
- maxFairness,
38
- }: {
39
- models: ModelData[]
40
- maxFairness: number
41
- }) {
42
- const [metric, setMetric] = useState<Metric>('luc')
43
- const [activeCreators, setCreators] = useState<Set<string>>(new Set())
44
-
45
- const allCreators = useMemo(
46
- () => [...new Set(models.filter(m => !m.archived).map(m => m.creator))].sort(),
47
- [models],
48
- )
49
-
50
- const toggleCreator = (c: string) =>
51
- setCreators(prev => { const s = new Set(prev); s.has(c) ? s.delete(c) : s.add(c); return s })
52
-
53
- const sorted = useMemo(() => {
54
- let list = models.filter(m => !m.archived)
55
- if (activeCreators.size) list = list.filter(m => activeCreators.has(m.creator))
56
- list = list.filter(m => getValue(m, metric) !== null)
57
- return [...list].sort((a, b) => {
58
- const va = getValue(a, metric)!
59
- const vb = getValue(b, metric)!
60
- return metric === 'fairness' ? va - vb : vb - va
61
- })
62
- }, [models, metric, activeCreators])
63
-
64
- const maxVal = metric === 'fairness' ? maxFairness : 1
65
- const n = sorted.length
66
- const bw = (CHART_W - (n - 1) * GAP) / n
67
-
68
- const legendCreators = useMemo(
69
- () => [...new Set(sorted.map(m => m.creator))].sort(),
70
- [sorted],
71
- )
72
-
73
- const tab = TABS.find(t => t.key === metric)!
74
-
75
- return (
76
- <div>
77
- {/* Filters row */}
78
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
79
- {/* Creator chips */}
80
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
81
- {allCreators.map(c => {
82
- const cc = creatorColor(c)
83
- const on = activeCreators.has(c)
84
- return (
85
- <button key={c} onClick={() => toggleCreator(c)} style={{
86
- height: 26, padding: '0 10px',
87
- border: `1.5px solid ${on ? cc : 'var(--border-1)'}`,
88
- borderRadius: 5, fontSize: 10, fontFamily: 'inherit', fontWeight: 700,
89
- color: on ? cc : 'var(--text-2)',
90
- background: on ? `${cc}18` : 'var(--bg-0)',
91
- cursor: 'pointer',
92
- transition: 'all 0.15s',
93
- display: 'inline-flex', alignItems: 'center', gap: 5,
94
- }}>
95
- <span style={{ display: 'inline-flex', alignItems: 'center', lineHeight: 0 }}>
96
- <LabLogo creator={c} size={14} />
97
- </span>
98
- {c}
99
- </button>
100
- )
101
- })}
102
- </div>
103
-
104
- {/* Metric tabs */}
105
- <div style={{ display: 'inline-flex', border: '1.5px solid var(--border-1)', borderRadius: 7, overflow: 'hidden', flexShrink: 0 }}>
106
- {TABS.map((t, i) => (
107
- <button key={t.key} onClick={() => setMetric(t.key)} style={{
108
- height: 28, padding: '0 13px',
109
- borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
110
- outline: 'none', border: i > 0 ? '1px solid var(--border-1)' : 'none',
111
- borderTop: 'none', borderRight: 'none', borderBottom: 'none',
112
- fontSize: 10, fontFamily: 'inherit', fontWeight: 700,
113
- letterSpacing: '0.05em', textTransform: 'uppercase' as const,
114
- color: metric === t.key ? 'white' : 'var(--text-2)',
115
- background: metric === t.key ? 'var(--accent)' : 'transparent',
116
- cursor: 'pointer', whiteSpace: 'nowrap' as const,
117
- transition: 'background 0.12s, color 0.12s',
118
- }}>
119
- {t.label}
120
- </button>
121
- ))}
122
- </div>
123
- </div>
124
-
125
- {/* Chart container */}
126
- <div style={{
127
- background: 'var(--bg-1)',
128
- border: '1.5px solid var(--border-1)',
129
- borderRadius: 10,
130
- padding: '1rem 1rem 0',
131
- boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
132
- }}>
133
- <svg
134
- viewBox={`0 0 ${VW} ${VH}`}
135
- style={{ width: '100%', height: 'auto', display: 'block' }}
136
- aria-label={`Bar chart: ${tab.label}`}
137
- >
138
- {/* Grid lines + y-axis labels */}
139
- {GRID.map(lvl => {
140
- const y = MT + BAR_H - lvl * BAR_H
141
- const display = metric === 'fairness'
142
- ? (lvl * maxVal).toFixed(2)
143
- : `${Math.round(lvl * 100)}`
144
- return (
145
- <g key={lvl}>
146
- <line
147
- x1={ML} y1={y} x2={VW - MR} y2={y}
148
- stroke={lvl === 0 ? 'var(--border-2)' : 'var(--border-0)'}
149
- strokeWidth={lvl === 0 ? 1 : 0.8}
150
- strokeDasharray={lvl === 0 || lvl === 1 ? undefined : '4 4'}
151
- />
152
- <text
153
- x={ML - 5} y={y + 3.5}
154
- textAnchor="end"
155
- fontSize={9}
156
- fill="var(--text-3)"
157
- fontFamily="inherit"
158
- >
159
- {display}
160
- </text>
161
- </g>
162
- )
163
- })}
164
-
165
- {/* Y-axis title */}
166
- <text
167
- x={11}
168
- y={MT + BAR_H / 2}
169
- textAnchor="middle"
170
- fontSize={8.5}
171
- fill="var(--text-3)"
172
- fontFamily="inherit"
173
- transform={`rotate(-90, 11, ${MT + BAR_H / 2})`}
174
- >
175
- {tab.yLabel}
176
- </text>
177
-
178
- {/* Bars + labels */}
179
- {sorted.map((m, i) => {
180
- const val = getValue(m, metric)!
181
- const pct = Math.min(1, val / maxVal)
182
- const barHeight = pct * BAR_H
183
- const x = ML + i * (bw + GAP)
184
- const y = MT + BAR_H - barHeight
185
- const cc = creatorColor(m.creator)
186
- const valLabel = metric === 'fairness'
187
- ? val.toFixed(3)
188
- : `${(val * 100).toFixed(0)}%`
189
-
190
- return (
191
- <g key={`${m.rank}-${metric}`}>
192
- {/* Bar */}
193
- <rect
194
- x={x} y={y}
195
- width={bw} height={barHeight}
196
- fill={cc} fillOpacity={0.88}
197
- rx={2}
198
- />
199
-
200
- {/* Value above bar */}
201
- {barHeight > 18 && (
202
- <text
203
- x={x + bw / 2} y={y - 4}
204
- textAnchor="middle"
205
- fontSize={8}
206
- fill={cc}
207
- fontFamily="inherit"
208
- fontWeight="bold"
209
- >
210
- {valLabel}
211
- </text>
212
- )}
213
-
214
- {/* Model name */}
215
- <text
216
- x={x + bw / 2}
217
- y={MT + BAR_H + 5}
218
- transform={`rotate(-45, ${x + bw / 2}, ${MT + BAR_H + 5})`}
219
- textAnchor="end"
220
- fontSize={10}
221
- fill="var(--text-1)"
222
- fontFamily="inherit"
223
- fontWeight="600"
224
- >
225
- {m.model}
226
- </text>
227
- </g>
228
- )
229
- })}
230
- </svg>
231
-
232
- {/* Creator legend */}
233
- <div style={{
234
- display: 'flex',
235
- flexWrap: 'wrap',
236
- gap: '8px 20px',
237
- justifyContent: 'center',
238
- padding: '12px 8px',
239
- borderTop: '1px solid var(--border-0)',
240
- marginTop: 4,
241
- }}>
242
- {legendCreators.map(c => (
243
- <div key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)', fontWeight: 600 }}>
244
- <div style={{ width: 11, height: 11, borderRadius: 2, background: creatorColor(c), flexShrink: 0 }} />
245
- {c}
246
- </div>
247
- ))}
248
- </div>
249
- </div>
250
- </div>
251
- )
252
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/ClusterView.tsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { ModelData } from '@/lib/types'
5
+ import ScoreDistribution from '@/components/ScoreDistribution'
6
+ import MetricBarAccordion from '@/components/MetricBarAccordion'
7
+
8
+ type View = 'dots' | 'bars'
9
+
10
+ const VIEWS: { key: View; label: string }[] = [
11
+ { key: 'dots', label: 'Dots' },
12
+ { key: 'bars', label: 'Bars' },
13
+ ]
14
+
15
+ export default function ClusterView({
16
+ models,
17
+ maxFairness,
18
+ }: {
19
+ models: ModelData[]
20
+ maxFairness: number
21
+ }) {
22
+ const [view, setView] = useState<View>('dots')
23
+
24
+ return (
25
+ <div>
26
+ {/* Dots ⇄ Bars toggle */}
27
+ <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
28
+ <div style={{ display: 'inline-flex', border: '1.5px solid var(--border-1)', borderRadius: 7, overflow: 'hidden' }}>
29
+ {VIEWS.map((v, i) => (
30
+ <button key={v.key} onClick={() => setView(v.key)} style={{
31
+ height: 28, padding: '0 16px',
32
+ border: 'none', borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
33
+ outline: 'none',
34
+ fontSize: 10, fontFamily: 'inherit', fontWeight: 700,
35
+ letterSpacing: '0.05em', textTransform: 'uppercase',
36
+ color: view === v.key ? 'white' : 'var(--text-2)',
37
+ background: view === v.key ? 'var(--accent)' : 'transparent',
38
+ cursor: 'pointer', transition: 'background 0.12s, color 0.12s',
39
+ }}>
40
+ {v.label}
41
+ </button>
42
+ ))}
43
+ </div>
44
+ </div>
45
+
46
+ {view === 'dots'
47
+ ? <ScoreDistribution models={models} maxFairness={maxFairness} />
48
+ : <MetricBarAccordion models={models} maxFairness={maxFairness} />
49
+ }
50
+ </div>
51
+ )
52
+ }
src/components/EvaluationShell.tsx CHANGED
@@ -6,7 +6,7 @@ import LeaderboardTable from '@/components/LeaderboardTable'
6
  import GuardrailsTable from '@/components/GuardrailsTable'
7
  import GuardrailDistribution from '@/components/GuardrailDistribution'
8
  import AboutSection from '@/components/AboutSection'
9
- import ScoreDistribution from '@/components/ScoreDistribution'
10
  import InsightsSection from '@/components/InsightsSection'
11
  import ScrollReveal from '@/components/ScrollReveal'
12
  import type { ModelData, GuardrailData, MetricThresholds, GuardrailThresholds } from '@/lib/types'
@@ -63,7 +63,7 @@ export default function EvaluationShell({ models, guardrails, maxFairness, thres
63
  Each dot is one active model, coloured by lab. The dashed line marks the field average.
64
  Fairness is inverted: dots further right are more equitable.
65
  </p>
66
- <ScoreDistribution models={models} maxFairness={maxFairness} />
67
  </div>
68
  </ScrollReveal>
69
  </div>
 
6
  import GuardrailsTable from '@/components/GuardrailsTable'
7
  import GuardrailDistribution from '@/components/GuardrailDistribution'
8
  import AboutSection from '@/components/AboutSection'
9
+ import ClusterView from '@/components/ClusterView'
10
  import InsightsSection from '@/components/InsightsSection'
11
  import ScrollReveal from '@/components/ScrollReveal'
12
  import type { ModelData, GuardrailData, MetricThresholds, GuardrailThresholds } from '@/lib/types'
 
63
  Each dot is one active model, coloured by lab. The dashed line marks the field average.
64
  Fairness is inverted: dots further right are more equitable.
65
  </p>
66
+ <ClusterView models={models} maxFairness={maxFairness} />
67
  </div>
68
  </ScrollReveal>
69
  </div>
src/components/MetricBarAccordion.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { ModelData } from '@/lib/types'
5
+ import MetricBars from '@/components/MetricBars'
6
+
7
+ type Metric = 'luc' | 'rag' | 'fairness'
8
+
9
+ const ROWS: { key: Metric; label: string }[] = [
10
+ { key: 'luc', label: 'Refusal Rate' },
11
+ { key: 'rag', label: 'RAG Score' },
12
+ { key: 'fairness', label: 'Fairness' },
13
+ ]
14
+
15
+ export default function MetricBarAccordion({
16
+ models,
17
+ maxFairness,
18
+ }: {
19
+ models: ModelData[]
20
+ maxFairness: number
21
+ }) {
22
+ const [open, setOpen] = useState<Metric>('luc')
23
+
24
+ return (
25
+ <div style={{ borderTop: '1px solid var(--border-0)' }}>
26
+ {ROWS.map(row => {
27
+ const isOpen = open === row.key
28
+ return (
29
+ <div key={row.key} style={{ borderBottom: '1px solid var(--border-0)' }}>
30
+ <button
31
+ onClick={() => setOpen(row.key)}
32
+ aria-expanded={isOpen}
33
+ style={{
34
+ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
35
+ padding: '14px 4px', background: 'transparent',
36
+ border: 'none',
37
+ cursor: 'pointer', fontFamily: 'inherit',
38
+ fontSize: 13, fontWeight: 700, color: 'var(--text-1)',
39
+ letterSpacing: '0.03em', textTransform: 'uppercase',
40
+ }}
41
+ >
42
+ {row.label}
43
+ <span style={{
44
+ display: 'inline-block', fontSize: 11, color: 'var(--text-3)',
45
+ transform: isOpen ? 'rotate(90deg)' : 'rotate(0deg)',
46
+ transition: 'transform 0.15s',
47
+ }}>
48
+
49
+ </span>
50
+ </button>
51
+ {isOpen && (
52
+ <div style={{ padding: '4px 0 20px' }}>
53
+ <MetricBars models={models} maxFairness={maxFairness} metric={row.key} />
54
+ </div>
55
+ )}
56
+ </div>
57
+ )
58
+ })}
59
+ </div>
60
+ )
61
+ }
src/components/MetricBars.tsx ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import type { ModelData } from '@/lib/types'
5
+ import { creatorColor } from '@/lib/utils'
6
+ import { LabLogo } from '@/components/LabLogo'
7
+
8
+ type Metric = 'luc' | 'rag' | 'fairness'
9
+
10
+ const Y_LABEL: Record<Metric, string> = {
11
+ luc: 'Score (Higher is Better)',
12
+ rag: 'Score (Higher is Better)',
13
+ fairness: 'Wasserstein Distance (Lower is Better)',
14
+ }
15
+
16
+ function getValue(m: ModelData, metric: Metric): number | null {
17
+ if (metric === 'luc') return m.luc.avg
18
+ if (metric === 'rag') return m.rag.avg
19
+ return m.fairness.avg
20
+ }
21
+
22
+ // SVG layout (coordinate units)
23
+ const ML = 80, MR = 80, MT = 14, BAR_H = 300, LBL_H = 150
24
+ const VW = 1100
25
+ const VH = MT + BAR_H + LBL_H
26
+ const CHART_W = VW - ML - MR
27
+ const GAP = 4
28
+ const GRID = [0, 0.25, 0.5, 0.75, 1.0]
29
+
30
+ export default function MetricBars({
31
+ models,
32
+ maxFairness,
33
+ metric,
34
+ }: {
35
+ models: ModelData[]
36
+ maxFairness: number
37
+ metric: Metric
38
+ }) {
39
+ const [activeCreators, setCreators] = useState<Set<string>>(new Set())
40
+
41
+ const allCreators = useMemo(
42
+ () => [...new Set(models.filter(m => !m.archived).map(m => m.creator))].sort(),
43
+ [models],
44
+ )
45
+
46
+ const toggleCreator = (c: string) =>
47
+ setCreators(prev => { const s = new Set(prev); s.has(c) ? s.delete(c) : s.add(c); return s })
48
+
49
+ const sorted = useMemo(() => {
50
+ let list = models.filter(m => !m.archived)
51
+ if (activeCreators.size) list = list.filter(m => activeCreators.has(m.creator))
52
+ list = list.filter(m => getValue(m, metric) !== null)
53
+ return [...list].sort((a, b) => {
54
+ const va = getValue(a, metric)!
55
+ const vb = getValue(b, metric)!
56
+ return metric === 'fairness' ? va - vb : vb - va
57
+ })
58
+ }, [models, metric, activeCreators])
59
+
60
+ const maxVal = metric === 'fairness' ? maxFairness : 1
61
+ const n = sorted.length
62
+ const bw = n > 0 ? (CHART_W - (n - 1) * GAP) / n : CHART_W
63
+
64
+ const legendCreators = useMemo(
65
+ () => [...new Set(sorted.map(m => m.creator))].sort(),
66
+ [sorted],
67
+ )
68
+
69
+ return (
70
+ <div>
71
+ {/* Lab filter chips */}
72
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: '1rem' }}>
73
+ {allCreators.map(c => {
74
+ const cc = creatorColor(c)
75
+ const on = activeCreators.has(c)
76
+ return (
77
+ <button key={c} onClick={() => toggleCreator(c)} style={{
78
+ height: 26, padding: '0 10px',
79
+ border: `1.5px solid ${on ? cc : 'var(--border-1)'}`,
80
+ borderRadius: 5, fontSize: 10, fontFamily: 'inherit', fontWeight: 700,
81
+ color: on ? cc : 'var(--text-2)',
82
+ background: on ? `${cc}18` : 'var(--bg-0)',
83
+ cursor: 'pointer', transition: 'all 0.15s',
84
+ display: 'inline-flex', alignItems: 'center', gap: 5,
85
+ }}>
86
+ <span style={{ display: 'inline-flex', alignItems: 'center', lineHeight: 0 }}>
87
+ <LabLogo creator={c} size={14} />
88
+ </span>
89
+ {c}
90
+ </button>
91
+ )
92
+ })}
93
+ </div>
94
+
95
+ {/* Chart container */}
96
+ <div style={{
97
+ background: 'var(--bg-1)',
98
+ border: '1.5px solid var(--border-1)',
99
+ borderRadius: 10,
100
+ padding: '1rem 1rem 0',
101
+ boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
102
+ }}>
103
+ <svg
104
+ viewBox={`0 0 ${VW} ${VH}`}
105
+ style={{ width: '100%', height: 'auto', display: 'block' }}
106
+ aria-label={`Bar chart: ${metric}`}
107
+ >
108
+ {/* Grid lines + y-axis labels */}
109
+ {GRID.map(lvl => {
110
+ const y = MT + BAR_H - lvl * BAR_H
111
+ const display = metric === 'fairness'
112
+ ? (lvl * maxVal).toFixed(2)
113
+ : `${Math.round(lvl * 100)}`
114
+ return (
115
+ <g key={lvl}>
116
+ <line
117
+ x1={ML} y1={y} x2={VW - MR} y2={y}
118
+ stroke={lvl === 0 ? 'var(--border-2)' : 'var(--border-0)'}
119
+ strokeWidth={lvl === 0 ? 1 : 0.8}
120
+ strokeDasharray={lvl === 0 || lvl === 1 ? undefined : '4 4'}
121
+ />
122
+ <text x={ML - 5} y={y + 3.5} textAnchor="end" fontSize={9}
123
+ fill="var(--text-3)" fontFamily="inherit">
124
+ {display}
125
+ </text>
126
+ </g>
127
+ )
128
+ })}
129
+
130
+ {/* Y-axis title */}
131
+ <text
132
+ x={11} y={MT + BAR_H / 2} textAnchor="middle"
133
+ fontSize={8.5} fill="var(--text-3)" fontFamily="inherit"
134
+ transform={`rotate(-90, 11, ${MT + BAR_H / 2})`}
135
+ >
136
+ {Y_LABEL[metric]}
137
+ </text>
138
+
139
+ {/* Bars + labels */}
140
+ {sorted.map((m, i) => {
141
+ const val = getValue(m, metric)!
142
+ const pct = Math.min(1, val / maxVal)
143
+ const barHeight = pct * BAR_H
144
+ const x = ML + i * (bw + GAP)
145
+ const y = MT + BAR_H - barHeight
146
+ const cc = creatorColor(m.creator)
147
+ const valLabel = metric === 'fairness' ? val.toFixed(3) : `${(val * 100).toFixed(0)}%`
148
+ return (
149
+ <g key={`${m.rank}-${metric}`}>
150
+ <rect x={x} y={y} width={bw} height={barHeight} fill={cc} fillOpacity={0.88} rx={2} />
151
+ {barHeight > 18 && (
152
+ <text x={x + bw / 2} y={y - 4} textAnchor="middle" fontSize={8}
153
+ fill={cc} fontFamily="inherit" fontWeight="bold">
154
+ {valLabel}
155
+ </text>
156
+ )}
157
+ <text
158
+ x={x + bw / 2} y={MT + BAR_H + 5}
159
+ transform={`rotate(-45, ${x + bw / 2}, ${MT + BAR_H + 5})`}
160
+ textAnchor="end" fontSize={10} fill="var(--text-1)"
161
+ fontFamily="inherit" fontWeight="600"
162
+ >
163
+ {m.model}
164
+ </text>
165
+ </g>
166
+ )
167
+ })}
168
+ </svg>
169
+
170
+ {/* Creator legend */}
171
+ <div style={{
172
+ display: 'flex', flexWrap: 'wrap', gap: '8px 20px', justifyContent: 'center',
173
+ padding: '12px 8px', borderTop: '1px solid var(--border-0)', marginTop: 4,
174
+ }}>
175
+ {legendCreators.map(c => (
176
+ <div key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)', fontWeight: 600 }}>
177
+ <div style={{ width: 11, height: 11, borderRadius: 2, background: creatorColor(c), flexShrink: 0 }} />
178
+ {c}
179
+ </div>
180
+ ))}
181
+ </div>
182
+ </div>
183
+ </div>
184
+ )
185
+ }