| |
1 | +/** |
| |
2 | + * llmindex.io — interactive live Pareto frontier chart (full-page, SVG) |
| |
3 | + * Author: Simon-Pierre Boucher |
| |
4 | + * Contact: contact@spboucher.ai |
| |
5 | + * License: Proprietary — © Simon-Pierre Boucher, all rights reserved |
| |
6 | + * |
| |
7 | + * Form: scatter + Pareto step line (quality vs log-cost). Color encodes the |
| |
8 | + * two-class identity frontier/dominated — palette #047857/#7c3aed validated |
| |
9 | + * (lightness band, chroma, CVD ΔE 23.9 deutan, normal-vision ΔE 32, contrast). |
| |
10 | + * Provider identity rides on labels/tooltips/logos, never on hue cycling. |
| |
11 | + * CI whiskers on every point; frontier points carry direct labels; per-mark |
| |
12 | + * hover tooltips with enlarged hit targets; polls fresh data every 15 s and |
| |
13 | + * animates newly landed models in. |
| |
14 | + */ |
| |
15 | +'use client'; |
| |
16 | + |
| |
17 | +import { useEffect, useMemo, useRef, useState } from 'react'; |
| |
18 | +import { useRouter } from 'next/navigation'; |
| |
19 | +import { ProviderLogo } from './ProviderLogo'; |
| |
20 | + |
| |
21 | +export interface FrontierPoint { |
| |
22 | + model: string; |
| |
23 | + name: string; |
| |
24 | + provider: string; |
| |
25 | + score: number; |
| |
26 | + score_low: number; |
| |
27 | + score_high: number; |
| |
28 | + latency_p50: number | null; |
| |
29 | + cost_per_1k_items: number; |
| |
30 | + on_frontier: boolean; |
| |
31 | +} |
| |
32 | + |
| |
33 | +const FRONTIER = '#047857'; |
| |
34 | +const DOMINATED = '#7c3aed'; |
| |
35 | +const INK = '#3f3f46'; |
| |
36 | +const INK_MUTED = '#a1a1aa'; |
| |
37 | +const GRID = '#e4e4e7'; |
| |
38 | +const POLL_MS = 15000; |
| |
39 | + |
| |
40 | +const W = 1280; |
| |
41 | +const H = 720; |
| |
42 | +const M = { top: 28, right: 48, bottom: 64, left: 72 }; |
| |
43 | +const IW = W - M.left - M.right; |
| |
44 | +const IH = H - M.top - M.bottom; |
| |
45 | + |
| |
46 | +function fmtCost(v: number): string { |
| |
47 | + if (v >= 100) return `$${Math.round(v)}`; |
| |
48 | + if (v >= 1) return `$${v.toFixed(v >= 10 ? 0 : 1)}`; |
| |
49 | + if (v >= 0.01) return `$${v.toFixed(2)}`; |
| |
50 | + return `$${v.toFixed(3)}`; |
| |
51 | +} |
| |
52 | + |
| |
53 | +export function FrontierChart({ initial }: { initial: FrontierPoint[] }) { |
| |
54 | + const router = useRouter(); |
| |
55 | + const [points, setPoints] = useState<FrontierPoint[]>(initial); |
| |
56 | + const [hover, setHover] = useState<FrontierPoint | null>(null); |
| |
57 | + const [fresh, setFresh] = useState<Set<string>>(new Set()); |
| |
58 | + const [updatedAt, setUpdatedAt] = useState<Date | null>(null); |
| |
59 | + const knownRef = useRef<Set<string>>(new Set(initial.map((p) => p.model))); |
| |
60 | + |
| |
61 | + useEffect(() => { |
| |
62 | + let cancelled = false; |
| |
63 | + async function tick() { |
| |
64 | + try { |
| |
65 | + const res = await fetch('/api/v1/efficiency', { cache: 'no-store' }); |
| |
66 | + if (!res.ok || cancelled) return; |
| |
67 | + const body = (await res.json()) as { points: FrontierPoint[] }; |
| |
68 | + if (!body.points) return; |
| |
69 | + const newcomers = body.points.map((p) => p.model).filter((m) => !knownRef.current.has(m)); |
| |
70 | + if (newcomers.length > 0) { |
| |
71 | + setFresh(new Set(newcomers)); |
| |
72 | + newcomers.forEach((m) => knownRef.current.add(m)); |
| |
73 | + setTimeout(() => setFresh(new Set()), 5000); |
| |
74 | + } |
| |
75 | + setPoints(body.points); |
| |
76 | + setUpdatedAt(new Date()); |
| |
77 | + } catch { |
| |
78 | + /* keep last state */ |
| |
79 | + } |
| |
80 | + } |
| |
81 | + const id = setInterval(tick, POLL_MS); |
| |
82 | + tick(); |
| |
83 | + return () => { |
| |
84 | + cancelled = true; |
| |
85 | + clearInterval(id); |
| |
86 | + }; |
| |
87 | + }, []); |
| |
88 | + |
| |
89 | + const view = useMemo(() => { |
| |
90 | + if (points.length === 0) return null; |
| |
91 | + const costs = points.map((p) => p.cost_per_1k_items).filter((c) => c > 0); |
| |
92 | + const minC = Math.min(...costs); |
| |
93 | + const maxC = Math.max(...costs); |
| |
94 | + const lo = Math.floor(Math.log10(minC) - 0.15); |
| |
95 | + const hi = Math.ceil(Math.log10(maxC) + 0.15); |
| |
96 | + const yMin = Math.max(0, Math.floor((Math.min(...points.map((p) => p.score_low)) - 50) / 100) * 100); |
| |
97 | + const yMax = Math.min(1000, Math.ceil((Math.max(...points.map((p) => p.score_high)) + 50) / 100) * 100); |
| |
98 | + |
| |
99 | + const x = (c: number): number => M.left + ((Math.log10(c) - lo) / (hi - lo)) * IW; |
| |
100 | + const y = (s: number): number => M.top + (1 - (s - yMin) / (yMax - yMin)) * IH; |
| |
101 | + |
| |
102 | + const xTicks: Array<{ v: number; major: boolean }> = []; |
| |
103 | + for (let d = lo; d <= hi; d++) { |
| |
104 | + xTicks.push({ v: 10 ** d, major: true }); |
| |
105 | + for (const m of [2, 5]) { |
| |
106 | + const v = m * 10 ** d; |
| |
107 | + if (Math.log10(v) < hi) xTicks.push({ v, major: false }); |
| |
108 | + } |
| |
109 | + } |
| |
110 | + const yTicks: number[] = []; |
| |
111 | + for (let s = yMin; s <= yMax; s += 100) yTicks.push(s); |
| |
112 | + |
| |
113 | + // Pareto step polyline (const-right steps in cost order). |
| |
114 | + const front = points.filter((p) => p.on_frontier).sort((a, b) => a.cost_per_1k_items - b.cost_per_1k_items); |
| |
115 | + let path = ''; |
| |
116 | + front.forEach((p, i) => { |
| |
117 | + const px = x(p.cost_per_1k_items); |
| |
118 | + const py = y(p.score); |
| |
119 | + if (i === 0) path += `M ${px} ${py}`; |
| |
120 | + else path += ` L ${px} ${y(front[i - 1]!.score)} L ${px} ${py}`; |
| |
121 | + }); |
| |
122 | + if (front.length > 0) { |
| |
123 | + path += ` L ${M.left + IW} ${y(front[front.length - 1]!.score)}`; |
| |
124 | + } |
| |
125 | + |
| |
126 | + // Direct labels on frontier points, nudged apart vertically when close. |
| |
127 | + const labels = front.map((p) => ({ p, lx: x(p.cost_per_1k_items) + 12, ly: y(p.score) - 10 })); |
| |
128 | + for (let i = 1; i < labels.length; i++) { |
| |
129 | + const prev = labels[i - 1]!; |
| |
130 | + const cur = labels[i]!; |
| |
131 | + if (Math.abs(cur.ly - prev.ly) < 16 && cur.lx - prev.lx < 130) cur.ly = prev.ly - 16; |
| |
132 | + } |
| |
133 | + |
| |
134 | + return { x, y, xTicks, yTicks, path, labels, yMin, yMax }; |
| |
135 | + }, [points]); |
| |
136 | + |
| |
137 | + if (!view || points.length === 0) { |
| |
138 | + return ( |
| |
139 | + <div className="flex h-[50vh] items-center justify-center rounded-2xl border border-zinc-200 bg-white text-sm text-zinc-500"> |
| |
140 | + Waiting for the first scored models — the chart populates automatically as the live |
| |
141 | + benchmark lands them. |
| |
142 | + </div> |
| |
143 | + ); |
| |
144 | + } |
| |
145 | + |
| |
146 | + const shortName = (slug: string): string => slug.split('/')[1] ?? slug; |
| |
147 | + |
| |
148 | + return ( |
| |
149 | + <div className="space-y-3"> |
| |
150 | + {/* legend + live status — one row above the chart */} |
| |
151 | + <div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm"> |
| |
152 | + <span className="flex items-center gap-2"> |
| |
153 | + <span className="inline-block h-3 w-3 rounded-full" style={{ background: FRONTIER }} /> |
| |
154 | + <span className="text-zinc-700">Pareto frontier ({points.filter((p) => p.on_frontier).length})</span> |
| |
155 | + </span> |
| |
156 | + <span className="flex items-center gap-2"> |
| |
157 | + <span className="inline-block h-3 w-3 rounded-full" style={{ background: DOMINATED, opacity: 0.75 }} /> |
| |
158 | + <span className="text-zinc-700">dominated ({points.filter((p) => !p.on_frontier).length})</span> |
| |
159 | + </span> |
| |
160 | + <span className="flex items-center gap-2 text-zinc-500"> |
| |
161 | + <span className="inline-block h-3 w-[2px] bg-zinc-400" /> 95% CI |
| |
162 | + </span> |
| |
163 | + <span className="ml-auto flex items-center gap-2 text-xs text-zinc-500"> |
| |
164 | + <span className="relative flex h-2 w-2"> |
| |
165 | + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-60" /> |
| |
166 | + <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" /> |
| |
167 | + </span> |
| |
168 | + live · {points.length} models{updatedAt ? ` · updated ${updatedAt.toLocaleTimeString()}` : ''} |
| |
169 | + </span> |
| |
170 | + </div> |
| |
171 | + |
| |
172 | + <div className="relative overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm"> |
| |
173 | + <svg viewBox={`0 0 ${W} ${H}`} className="block w-full" role="img" |
| |
174 | + aria-label="Pareto efficiency frontier: Global Index versus cost per thousand items"> |
| |
175 | + {/* grid */} |
| |
176 | + {view.yTicks.map((s) => ( |
| |
177 | + <line key={`gy${s}`} x1={M.left} x2={M.left + IW} y1={view.y(s)} y2={view.y(s)} |
| |
178 | + stroke={GRID} strokeWidth={1} /> |
| |
179 | + ))} |
| |
180 | + {view.xTicks.filter((t) => t.major).map((t) => ( |
| |
181 | + <line key={`gx${t.v}`} x1={view.x(t.v)} x2={view.x(t.v)} y1={M.top} y2={M.top + IH} |
| |
182 | + stroke={GRID} strokeWidth={1} /> |
| |
183 | + ))} |
| |
184 | + |
| |
185 | + {/* axes labels */} |
| |
186 | + {view.yTicks.map((s) => ( |
| |
187 | + <text key={`ty${s}`} x={M.left - 10} y={view.y(s) + 4} textAnchor="end" |
| |
188 | + fontSize={13} fill={INK_MUTED}>{s}</text> |
| |
189 | + ))} |
| |
190 | + {view.xTicks.map((t) => ( |
| |
191 | + <text key={`tx${t.v}`} x={view.x(t.v)} y={M.top + IH + (t.major ? 24 : 20)} textAnchor="middle" |
| |
192 | + fontSize={t.major ? 13 : 10.5} fill={t.major ? INK_MUTED : '#c4c4cc'}> |
| |
193 | + {fmtCost(t.v)} |
| |
194 | + </text> |
| |
195 | + ))} |
| |
196 | + <text x={M.left + IW / 2} y={H - 14} textAnchor="middle" fontSize={14} fill={INK}> |
| |
197 | + cost per 1,000 items (USD, log scale) → |
| |
198 | + </text> |
| |
199 | + <text x={20} y={M.top + IH / 2} textAnchor="middle" fontSize={14} fill={INK} |
| |
200 | + transform={`rotate(-90 20 ${M.top + IH / 2})`}> |
| |
201 | + Global Index → |
| |
202 | + </text> |
| |
203 | + |
| |
204 | + {/* frontier step line */} |
| |
205 | + <path d={view.path} fill="none" stroke={FRONTIER} strokeWidth={2} strokeLinejoin="round" opacity={0.85} /> |
| |
206 | + |
| |
207 | + {/* CI whiskers */} |
| |
208 | + {points.map((p) => { |
| |
209 | + const px = view.x(p.cost_per_1k_items); |
| |
210 | + const color = p.on_frontier ? FRONTIER : DOMINATED; |
| |
211 | + return ( |
| |
212 | + <g key={`w${p.model}`} opacity={0.45}> |
| |
213 | + <line x1={px} x2={px} y1={view.y(p.score_low)} y2={view.y(p.score_high)} |
| |
214 | + stroke={color} strokeWidth={1.5} /> |
| |
215 | + <line x1={px - 4} x2={px + 4} y1={view.y(p.score_low)} y2={view.y(p.score_low)} stroke={color} strokeWidth={1.5} /> |
| |
216 | + <line x1={px - 4} x2={px + 4} y1={view.y(p.score_high)} y2={view.y(p.score_high)} stroke={color} strokeWidth={1.5} /> |
| |
217 | + </g> |
| |
218 | + ); |
| |
219 | + })} |
| |
220 | + |
| |
221 | + {/* dots (2px surface ring separates overlapping marks) */} |
| |
222 | + {points.map((p) => ( |
| |
223 | + <circle key={`d${p.model}`} cx={view.x(p.cost_per_1k_items)} cy={view.y(p.score)} |
| |
224 | + r={p.on_frontier ? 7 : 5.5} |
| |
225 | + fill={p.on_frontier ? FRONTIER : DOMINATED} |
| |
226 | + fillOpacity={p.on_frontier ? 1 : 0.75} |
| |
227 | + stroke="#ffffff" strokeWidth={2} |
| |
228 | + className={fresh.has(p.model) ? 'animate-row-in' : undefined} /> |
| |
229 | + ))} |
| |
230 | + |
| |
231 | + {/* selective direct labels: frontier only */} |
| |
232 | + {view.labels.map(({ p, lx, ly }) => ( |
| |
233 | + <text key={`l${p.model}`} x={lx} y={ly} fontSize={12.5} fontWeight={600} fill={INK}> |
| |
234 | + {shortName(p.model)} |
| |
235 | + </text> |
| |
236 | + ))} |
| |
237 | + |
| |
238 | + {/* enlarged invisible hit targets */} |
| |
239 | + {points.map((p) => ( |
| |
240 | + <circle key={`h${p.model}`} cx={view.x(p.cost_per_1k_items)} cy={view.y(p.score)} r={16} |
| |
241 | + fill="transparent" style={{ cursor: 'pointer' }} |
| |
242 | + onMouseEnter={() => setHover(p)} onMouseLeave={() => setHover(null)} |
| |
243 | + onClick={() => router.push(`/models/${p.model}`)} /> |
| |
244 | + ))} |
| |
245 | + |
| |
246 | + {/* hover ring */} |
| |
247 | + {hover && ( |
| |
248 | + <circle cx={view.x(hover.cost_per_1k_items)} cy={view.y(hover.score)} r={11} |
| |
249 | + fill="none" stroke={hover.on_frontier ? FRONTIER : DOMINATED} strokeWidth={2} opacity={0.6} /> |
| |
250 | + )} |
| |
251 | + </svg> |
| |
252 | + |
| |
253 | + {/* tooltip */} |
| |
254 | + {hover && ( |
| |
255 | + <div |
| |
256 | + className="pointer-events-none absolute z-10 w-64 rounded-xl border border-zinc-200 bg-white p-3 shadow-lg" |
| |
257 | + style={{ |
| |
258 | + left: `min(max(${((view.x(hover.cost_per_1k_items) / W) * 100).toFixed(2)}% - 8rem, 0.5rem), calc(100% - 16.5rem))`, |
| |
259 | + top: `calc(${((view.y(hover.score) / H) * 100).toFixed(2)}% - 0.5rem)`, |
| |
260 | + transform: 'translateY(-100%)', |
| |
261 | + }} |
| |
262 | + > |
| |
263 | + <div className="flex items-center gap-2"> |
| |
264 | + <ProviderLogo provider={hover.provider} size={18} /> |
| |
265 | + <span className="truncate text-sm font-semibold text-zinc-900">{hover.name}</span> |
| |
266 | + </div> |
| |
267 | + <div className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-zinc-600"> |
| |
268 | + <span>Global Index</span> |
| |
269 | + <span className="text-right font-semibold tabular-nums text-zinc-900"> |
| |
270 | + {hover.score} <span className="font-normal text-zinc-500">[{hover.score_low}–{hover.score_high}]</span> |
| |
271 | + </span> |
| |
272 | + <span>Cost / 1k items</span> |
| |
273 | + <span className="text-right tabular-nums">{fmtCost(hover.cost_per_1k_items)}</span> |
| |
274 | + <span>Latency p50</span> |
| |
275 | + <span className="text-right tabular-nums"> |
| |
276 | + {hover.latency_p50 != null ? `${(hover.latency_p50 / 1000).toFixed(1)}s` : '—'} |
| |
277 | + </span> |
| |
278 | + <span>Status</span> |
| |
279 | + <span className="text-right"> |
| |
280 | + {hover.on_frontier ? ( |
| |
281 | + <span className="rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700">frontier</span> |
| |
282 | + ) : ( |
| |
283 | + <span className="rounded-full bg-violet-100 px-2 py-0.5 text-violet-700">dominated</span> |
| |
284 | + )} |
| |
285 | + </span> |
| |
286 | + </div> |
| |
287 | + <p className="mt-2 text-[10px] text-zinc-400">click to open the model page</p> |
| |
288 | + </div> |
| |
289 | + )} |
| |
290 | + </div> |
| |
291 | + </div> |
| |
292 | + ); |
| |
293 | +} |