|
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 |
+} |