SPB Git

spb/llmindex

Public

The discriminative, contamination-resistant, fully transparent LLM ranking — updated live.

feat(web): dedicated full-page interactive live Pareto frontier chart (/frontier)

Hand-rolled SVG scatter: log-cost x-axis, Global Index y-axis with 95% CI
whiskers on every point, Pareto step line, direct labels on frontier models,
per-mark hover tooltips (score/CI, cost, latency, status) with enlarged hit
targets, click-through to model pages, 15s live polling with entry animation
for newly landed models. Two-class palette #047857/#7c3aed validated (CVD
deutan dE 23.9, contrast >=3:1). Nav link + home cross-link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 5, 2026) parent 45b821d Browse files

6 changed files +423 −11

modified apps/web/app/api/v1/efficiency/route.ts +4 −0
@@ -19,7 +19,11 @@ export async function GET(req: NextRequest) {
19 points: points.map((p) => ({ 19 points: points.map((p) => ({
20 model: p.slug, 20 model: p.slug,
21 name: p.name, 21 name: p.name,
22+ provider: p.provider,
22 score: p.score, 23 score: p.score,
24+ score_low: p.scoreLow,
25+ score_high: p.scoreHigh,
26+ latency_p50: p.latencyP50,
23 cost_per_1k_items: Number(p.costPer1kItems.toFixed(4)), 27 cost_per_1k_items: Number(p.costPer1kItems.toFixed(4)),
24 on_frontier: p.onFrontier, 28 on_frontier: p.onFrontier,
25 })), 29 })),
added apps/web/app/frontier/page.tsx +69 −0
@@ -0,0 +1,69 @@
1+/**
2+ * llmindex.io — dedicated full-page live efficiency frontier
3+ * Author: Simon-Pierre Boucher
4+ * Contact: contact@spboucher.ai
5+ * License: Proprietary — © Simon-Pierre Boucher, all rights reserved
6+ */
7+import type { Metadata } from 'next';
8+import Link from 'next/link';
9+import { FrontierChart, type FrontierPoint } from '@/components/FrontierChart';
10+import { getEfficiencyData } from '@/lib/data';
11+
12+export const metadata: Metadata = {
13+ title: 'Efficiency Frontier',
14+ description:
15+ 'Live Pareto frontier of LLM quality vs. measured cost — every point with 95% confidence intervals, updated automatically as models finish the benchmark.',
16+};
17+export const revalidate = 300;
18+
19+export default async function FrontierPage() {
20+ const data = await getEfficiencyData();
21+ const initial: FrontierPoint[] = data.map((p) => ({
22+ model: p.slug,
23+ name: p.name,
24+ provider: p.provider,
25+ score: p.score,
26+ score_low: p.scoreLow,
27+ score_high: p.scoreHigh,
28+ latency_p50: p.latencyP50,
29+ cost_per_1k_items: Number(p.costPer1kItems.toFixed(4)),
30+ on_frontier: p.onFrontier,
31+ }));
32+
33+ return (
34+ <div className="space-y-6">
35+ <div className="space-y-2">
36+ <h1 className="text-2xl font-bold tracking-tight text-zinc-900 sm:text-3xl">
37+ Efficiency frontier
38+ </h1>
39+ <p className="max-w-3xl text-sm leading-relaxed text-zinc-600">
40+ Global Index versus <em>measured</em> cost per 1,000 evaluation items (metered tokens ×
41+ live pricing), on a log scale. Models on the{' '}
42+ <span className="font-semibold text-emerald-700">frontier</span> are not dominated on
43+ both axes; every other model is strictly worse on quality <em>and</em> cost than some
44+ frontier point. By design this is published as a set — never collapsed into a single
45+ blended “value” number. The chart updates itself as the live benchmark lands each model.
46+ </p>
47+ </div>
48+
49+ {/* full-bleed chart: break out of the content column on large screens */}
50+ <div className="lg:relative lg:left-1/2 lg:w-[min(96vw,1500px)] lg:-translate-x-1/2">
51+ <FrontierChart initial={initial} />
52+ </div>
53+
54+ <p className="text-sm text-zinc-500">
55+ Prefer numbers? The same data lives in the{' '}
56+ <Link href="/" className="underline hover:text-emerald-600">
57+ leaderboard table
58+ </Link>{' '}
59+ and the{' '}
60+ <a href="/api/v1/efficiency" className="underline hover:text-emerald-600">
61+ public API
62+ </a>
63+ . Methodology: cost is the mean measured cost per item across a model&apos;s scored
64+ domains × 1,000; whiskers are the 95% CI of the Global Index; the frontier is computed on
65+ (score ↑, cost ↓) dominance.
66+ </p>
67+ </div>
68+ );
69+}
modified apps/web/app/layout.tsx +3 −0
@@ -38,6 +38,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
38 <Link href="/" className="text-zinc-600 transition-colors hover:text-zinc-900"> 38 <Link href="/" className="text-zinc-600 transition-colors hover:text-zinc-900">
39 Leaderboard 39 Leaderboard
40 </Link> 40 </Link>
41+ <Link href="/frontier" className="text-zinc-600 transition-colors hover:text-zinc-900">
42+ Frontier
43+ </Link>
41 <Link href="/methodology" className="text-zinc-600 transition-colors hover:text-zinc-900"> 44 <Link href="/methodology" className="text-zinc-600 transition-colors hover:text-zinc-900">
42 Methodology 45 Methodology
43 </Link> 46 </Link>
modified apps/web/components/EfficiencyLive.tsx +9 −1
@@ -48,7 +48,15 @@ export function EfficiencyLive({ initial }: { initial: EffPoint[] }) {
48 48
49 return ( 49 return (
50 <section className="space-y-3"> 50 <section className="space-y-3">
51− <h2 className="text-lg font-semibold text-zinc-900 sm:text-xl">Efficiency frontier</h2> 51+ <div className="flex flex-wrap items-baseline justify-between gap-2">
52+ <h2 className="text-lg font-semibold text-zinc-900 sm:text-xl">Efficiency frontier</h2>
53+ <Link
54+ href="/frontier"
55+ className="rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 transition-colors hover:border-emerald-400"
56+ >
57+ Open the full interactive chart →
58+ </Link>
59+ </div>
52 <p className="text-sm text-zinc-500"> 60 <p className="text-sm text-zinc-500">
53 Score vs. measured cost per 1k items — refreshed live as models land. Frontier models are 61 Score vs. measured cost per 1k items — refreshed live as models land. Frontier models are
54 not dominated on both axes; never collapsed into a single blended number. 62 not dominated on both axes; never collapsed into a single blended number.
added apps/web/components/FrontierChart.tsx +293 −0
@@ -0,0 +1,293 @@
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+}
modified apps/web/lib/data.ts +45 −10
@@ -173,6 +173,10 @@ export async function getModelProfile(slug: string): Promise<ModelProfile | null
173 173
174 export interface EfficiencyPoint extends ParetoPoint { 174 export interface EfficiencyPoint extends ParetoPoint {
175 name: string; 175 name: string;
176+ provider: string;
177+ scoreLow: number;
178+ scoreHigh: number;
179+ latencyP50: number | null;
176 onFrontier: boolean; 180 onFrontier: boolean;
177 } 181 }
178 182
@@ -288,29 +292,60 @@ export async function getEfficiencyData(): Promise<EfficiencyPoint[]> {
288 if (!run) return []; 292 if (!run) return [];
289 const scores = await prisma.score.findMany({ 293 const scores = await prisma.score.findMany({
290 where: { runId: run.id }, 294 where: { runId: run.id },
291− include: { model: { select: { slug: true, name: true } } }, 295+ include: { model: { select: { slug: true, name: true, provider: true } } },
292 }); 296 });
293− const byModel = new Map<string, { name: string; global: number | null; costs: number[] }>(); 297+ interface Acc {
298+ name: string;
299+ provider: string;
300+ global: { score: number; low: number; high: number } | null;
301+ costs: number[];
302+ latencies: number[];
303+ }
304+ const byModel = new Map<string, Acc>();
294 for (const s of scores) { 305 for (const s of scores) {
295− const entry = byModel.get(s.model.slug) ?? { name: s.model.name, global: null, costs: [] }; 306+ const entry: Acc = byModel.get(s.model.slug) ?? {
296− if (s.domain === GLOBAL_DOMAIN) entry.global = s.score; 307+ name: s.model.name,
297− const cost = (s.subMetrics as Record<string, number> | null)?.cost_per_1k_items; 308+ provider: s.model.provider,
298− if (typeof cost === 'number') entry.costs.push(cost); 309+ global: null,
310+ costs: [],
311+ latencies: [],
312+ };
313+ if (s.domain === GLOBAL_DOMAIN)
314+ entry.global = { score: s.score, low: s.scoreLow, high: s.scoreHigh };
315+ const sub = s.subMetrics as Record<string, number> | null;
316+ if (typeof sub?.cost_per_1k_items === 'number') entry.costs.push(sub.cost_per_1k_items);
317+ if (typeof sub?.latency_p50 === 'number') entry.latencies.push(sub.latency_p50);
299 byModel.set(s.model.slug, entry); 318 byModel.set(s.model.slug, entry);
300 } 319 }
301 const points: ParetoPoint[] = []; 320 const points: ParetoPoint[] = [];
302− const names = new Map<string, string>(); 321+ const meta = new Map<string, Acc>();
303 for (const [slug, e] of byModel) { 322 for (const [slug, e] of byModel) {
304 if (e.global === null || e.costs.length === 0) continue; 323 if (e.global === null || e.costs.length === 0) continue;
305− names.set(slug, e.name); 324+ meta.set(slug, e);
306 points.push({ 325 points.push({
307 slug, 326 slug,
308− score: e.global, 327+ score: e.global.score,
309 costPer1kItems: e.costs.reduce((a, b) => a + b, 0) / e.costs.length, 328 costPer1kItems: e.costs.reduce((a, b) => a + b, 0) / e.costs.length,
310 }); 329 });
311 } 330 }
312 const frontier = new Set(paretoFrontier(points).map((p) => p.slug)); 331 const frontier = new Set(paretoFrontier(points).map((p) => p.slug));
332+ const median = (arr: number[]): number | null => {
333+ if (!arr.length) return null;
334+ const s = [...arr].sort((a, b) => a - b);
335+ return s[Math.floor(s.length / 2)]!;
336+ };
313 return points 337 return points
314− .map((p) => ({ ...p, name: names.get(p.slug) ?? p.slug, onFrontier: frontier.has(p.slug) })) 338+ .map((p) => {
339+ const e = meta.get(p.slug)!;
340+ return {
341+ ...p,
342+ name: e.name,
343+ provider: e.provider,
344+ scoreLow: e.global!.low,
345+ scoreHigh: e.global!.high,
346+ latencyP50: median(e.latencies),
347+ onFrontier: frontier.has(p.slug),
348+ };
349+ })
315 .sort((a, b) => a.costPer1kItems - b.costPer1kItems); 350 .sort((a, b) => a.costPer1kItems - b.costPer1kItems);
316 } 351 }
317 352
Menu

Theme