hub-dollars / index.html
thomwolf's picture
thomwolf HF Staff
Curve: split by evidence view (suggested by @foundrcode)
3e22cb7 verified
Raw History Blame Contribute Delete
26.5 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hub Dollars</title>
<meta name="description" content="How much training compute is shared for free on the Hugging Face Hub? A source-linked estimate, model family by model family.">
<style>
:root {
--ink: #0F172A; --muted: #64748B; --line: #E2E8F0; --soft: #F8FAFC;
--blue: #2563EB; --blue-deep: #1E3A8A; --blue-mid: #60A5FA; --blue-light: #BFDBFE;
--bg: #FFFFFF;
color-scheme: light;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.55 "Helvetica Neue", Helvetica, Arial, system-ui, sans-serif; }
main { max-width: 1080px; margin: 0 auto; padding: 40px 16px 80px; }
a { color: var(--blue); }
.kicker { color: var(--blue); font-weight: 700; letter-spacing: .04em; text-transform: uppercase; font-size: 13px; }
h1 { font-size: clamp(28px, 4.4vw, 44px); line-height: 1.12; margin: 8px 0 12px; letter-spacing: -.01em; }
h2 { font-size: 22px; margin: 56px 0 6px; }
.lede { color: var(--muted); font-size: 18px; max-width: 760px; margin: 0; }
.sub { color: var(--muted); margin: 0 0 16px; max-width: 760px; }
.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 28px 0 8px; }
.tile { background: var(--soft); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; }
.tile b { display: block; font-size: 28px; color: var(--blue-deep); line-height: 1.15; }
.tile span { color: var(--muted); font-size: 13.5px; }
.card { border: 1px solid var(--line); border-radius: 14px; padding: 16px; background: #fff; }
.controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; font-size: 14px; color: var(--muted); }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
.seg button { border: 0; background: #fff; color: var(--ink); padding: 6px 12px; font: inherit; cursor: pointer; }
.seg button.on { background: var(--blue); color: #fff; }
svg { display: block; width: 100%; height: auto; overflow: visible; }
svg text { font-family: inherit; }
.tip { position: fixed; pointer-events: none; background: var(--ink); color: #fff; font-size: 13px; line-height: 1.4;
padding: 8px 10px; border-radius: 8px; max-width: 280px; opacity: 0; transition: opacity .1s; z-index: 10; }
.note { color: var(--muted); font-size: 13px; margin-top: 8px; }
.evbar { display: flex; height: 34px; border-radius: 8px; overflow: hidden; gap: 2px; margin: 12px 0 10px; }
.evbar div { display: flex; align-items: center; padding: 0 10px; color: #fff; font-weight: 700; font-size: 14px; white-space: nowrap; min-width: 0; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 14px; color: var(--muted); }
.legend i { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
.tablebar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
input[type=search], select { font: inherit; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; min-width: 0; }
input[type=search] { flex: 1 1 220px; }
.tablewrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; }
table { border-collapse: collapse; width: 100%; font-size: 14px; }
th, td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--line); vertical-align: top; }
th { background: var(--soft); font-weight: 600; cursor: pointer; white-space: nowrap; user-select: none; position: sticky; top: 0; }
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: 0; }
td .rng { color: var(--muted); font-size: 12.5px; display: block; }
.pill { display: inline-block; font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
.src a { margin-right: 6px; text-decoration: none; font-size: 13px; }
ul.method { padding-left: 20px; max-width: 800px; }
ul.method li { margin: 6px 0; }
.downloads a { display: inline-block; margin: 4px 14px 4px 0; }
footer { margin-top: 56px; color: var(--muted); font-size: 13px; border-top: 1px solid var(--line); padding-top: 16px; }
@media (max-width: 760px) { .tiles { grid-template-columns: repeat(2, 1fr); } .tile b { font-size: 22px; } }
</style>
</head>
<body>
<main>
<div class="kicker">Hub Dollars</div>
<h1>About $1B of training compute is shared for free on the Hugging Face Hub</h1>
<p class="lede">A rough estimate of what it would cost to rent the GPUs that trained the open-weight models on the Hub. It covers the final training runs of the main model families, not the long tail.</p>
<div class="tiles">
<div class="tile"><b id="t-central">…</b><span>central estimate</span></div>
<div class="tile"><b id="t-range">…</b><span>10th to 90th percentile</span></div>
<div class="tile"><b id="t-fam">…</b><span>model families, <span id="t-org">…</span> organizations</span></div>
<div class="tile"><b id="t-doc">…</b><span>from compute figures the developers published</span></div>
</div>
<h2>Cumulative cost over time</h2>
<p class="sub">Each family's cost is placed at its first or latest release month. Shaded: 10th to 90th percentile. "Split by evidence" shows how much rests on published numbers.</p>
<div class="card">
<div class="controls">Place cost at
<span class="seg" id="booking"><button data-v="late" class="on">latest release</button><button data-v="early">first release</button></span>
<span style="margin-left:12px">Show</span>
<span class="seg" id="view"><button data-v="total" class="on">total and range</button><button data-v="evidence">split by evidence</button></span>
</div>
<svg id="curve" viewBox="0 0 1000 440" role="img" aria-label="Cumulative training compute cost over time"></svg>
<div class="legend" id="curvelegend" style="display:none;margin-top:6px"></div>
</div>
<h2>By organization</h2>
<p class="sub">Bar: central estimate. Line: 10th to 90th percentile.</p>
<div class="card"><svg id="orgs" role="img" aria-label="Estimated cost by organization"></svg></div>
<h2>Evidence behind the estimate</h2>
<p class="sub">Share of the central estimate by type of evidence.</p>
<div class="evbar" id="evbar"></div>
<div class="legend" id="evlegend"></div>
<h2>All model families</h2>
<p class="sub">Costs in $M. Click a column to sort.</p>
<div class="tablebar">
<input type="search" id="q" placeholder="Search a model or organization">
<select id="ev"><option value="">All evidence</option><option value="A">Reported GPU-hours</option><option value="B">Reported size and tokens</option><option value="C">Partly assumed</option></select>
</div>
<div class="tablewrap"><table id="tbl">
<thead><tr>
<th data-k="organization">Organization</th><th data-k="family">Family</th><th data-k="first">Released</th>
<th data-k="grade">Evidence</th><th>Compute basis</th>
<th data-k="central_musd" class="num">Cost ($M)</th><th>Sources</th>
</tr></thead><tbody></tbody>
</table></div>
<h2>Method</h2>
<ul class="method">
<li><b>Counted:</b> the final pre-training and post-training runs of 75 official model families.</li>
<li><b>Not counted:</b> community fine-tunes, LoRAs and merges (about 900k Hub repos declare a parent model), smaller families outside the list, experiments and failed runs, salaries, datasets. So this is a floor on what was spent, not the full cost.</li>
<li><b>Prices:</b> $2.5 per H100-hour, $2 per A100 or H800-hour, $5 per B200-hour. When only FLOPs are known, $1.8M per 10²⁴ FLOP (H100 at $2.5/hour, 40% utilization).</li>
<li><b>Compute:</b> GPU-hours from the model card when published. Otherwise 6 × active parameters × training tokens. When tokens aren't published they are guessed, and the family is marked "partly assumed".</li>
<li><b>Overlap:</b> a base model is counted once. Official variants only add their extra training. Quantizations and mirrors count as zero.</li>
<li><b>Range (<span id="m-range"></span>):</b> 20,000 simulated runs varying the GPU price ($2 to $3 per H100-hour), utilization (35% to 50%) and each family's training quantities between its low and high estimates. Families with guessed token counts move together. Setting every assumption to its extreme at once gives <span id="m-extreme"></span>.</li>
<li><b>Dates:</b> some release months come from Epoch AI publication dates.</li>
</ul>
<h2>Data</h2>
<p class="downloads">
<a href="v4/families-v4.csv" download>Families (CSV)</a>
<a href="v4/organizations-v4.csv" download>Organizations (CSV)</a>
<a href="timeline/cumulative.csv" download>Monthly cumulative (CSV)</a>
<a href="timeline/cumulative-realistic.csv" download>Monthly realistic range (CSV)</a>
<a href="timeline/release-windows.csv" download>Release dates and sources (CSV)</a>
<a href="v4/families-realistic.json">Realistic ranges per family (JSON)</a>
<a href="v4/summary-realistic.json">Realistic range summary (JSON)</a>
<a href="scripts/realistic_bounds.py">Simulation script</a>
<a href="v4/summary.json">Summary (JSON)</a>
<a href="audit.html">Previous detailed audit (French)</a>
</p>
<footer>Estimate as of <span id="asof">October 2026</span>. Sources: model cards, technical reports and the <a href="https://epoch.ai/data">Epoch AI</a> model database. If a number is wrong, open a discussion on the Space.</footer>
</main>
<div class="tip" id="tip"></div>
<script>
const C = { ink: "#0F172A", muted: "#64748B", line: "#E2E8F0", blue: "#2563EB", deep: "#1E3A8A", mid: "#60A5FA", light: "#BFDBFE" };
const GRADE = {
A: { label: "Reported GPU-hours", color: C.deep, text: "#fff" },
B: { label: "Reported size and tokens", color: C.blue, text: "#fff" },
C: { label: "Partly assumed", color: C.light, text: C.deep },
};
const NOTABLE = [
["2022-07", "BLOOM"], ["2023-07", "Llama 2"], ["2023-12", "Mixtral"], ["2024-04", "Llama 3"],
["2024-07", "Llama 3.1 405B"], ["2024-12", "DeepSeek-V3"], ["2025-04", "Qwen3"], ["2025-07", "Kimi K2"],
["2025-08", "gpt-oss"], ["2026-04", "DeepSeek-V4"], ["2026-07", "Kimi K3"],
];
const NS = "http://www.w3.org/2000/svg";
const el = (tag, attrs = {}, parent) => { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (parent) parent.appendChild(e); return e; };
const fmtM = v => v >= 1000 ? `$${(v / 1000).toFixed(2)}B` : `$${Math.round(v)}M`;
const monthName = m => new Date(m + "-15").toLocaleDateString("en-US", { month: "short", year: "numeric" });
const tip = document.getElementById("tip");
const showTip = (html, ev) => { tip.innerHTML = html; tip.style.opacity = 1; const x = Math.min(ev.clientX + 14, innerWidth - 300); tip.style.left = x + "px"; tip.style.top = (ev.clientY + 14) + "px"; };
const hideTip = () => { tip.style.opacity = 0; };
function basisOf(f) {
const m = f.method || "", num = x => +(+x).toPrecision(3);
if (/^(Budget inconnu|Scénario|Cumulative shared-backbone|Reported cluster)/.test(m)) return "Scenario (budget not published)";
if (Array.isArray(f.flops_1e24)) return `${num(f.flops_1e24[1])} × 10²⁴ FLOP`;
let r;
if ((r = m.match(/10\^24 FLOP\)[^\[]*\[([\d.]+), ([\d.]+)/))) return `${num(r[2])} × 10²⁴ FLOP`;
if ((r = m.match(/1e24×\[([\d.]+)(?:, ([\d.]+))?/))) return `${num(r[2] || r[1])} × 10²⁴ FLOP`;
if ((r = m.match(/([\d.]+) millions? heures (\w+)/))) return `${num(r[1])}M ${r[2]} GPU-hours`;
if ((r = m.match(/([\d.]+)M (H100|A100|H800|B200)-h/))) return `${num(r[1])}M+ ${r[2]} GPU-hours`;
if ((r = m.match(/([\d.]+)M heures (\w+)/))) return `${num(r[1])}M ${r[2]} GPU-hours`;
if ((r = m.match(/(\d{5,}) GPU-hours/))) return `${num(r[1] / 1e6)}M GPU-hours`;
if ((r = m.match(/([\d.]+)B × \[([\d.]+), ([\d.]+)/))) return `${r[1]}B params × ${r[3]}T tokens`;
return "Scenario (budget not published)";
}
const gradeOf = f => f.evidence_grade || (/tokens reported/.test(f.method) ? "B" : "C");
Promise.all([
fetch("v4/summary.json").then(r => r.json()),
fetch("v4/families-v4.json").then(r => r.json()),
fetch("v4/organizations-v4.json").then(r => r.json()),
fetch("timeline/cumulative.json").then(r => r.json()),
fetch("timeline/releases.json").then(r => r.json()),
fetch("timeline/release-windows.json").then(r => r.json()),
fetch("v4/summary-realistic.json").then(r => r.json()),
fetch("v4/families-realistic.json").then(r => r.json()),
fetch("v4/organizations-realistic.json").then(r => r.json()),
fetch("timeline/cumulative-realistic.json").then(r => r.json()),
]).then(([summary, families, orgs, cumulative, releases, windows, real, realFam, realOrg, band]) => {
cumulative.forEach((r, i) => Object.assign(r, band[i]));
const t = summary.selected_total;
// replace the all-extremes-at-once scenarios by the simulated realistic ranges
const rk = x => x.organization + "|" + (x.family || "");
const rf = Object.fromEntries(realFam.map(x => [rk(x), x])), ro = Object.fromEntries(realOrg.map(x => [rk(x), x]));
[...families, ...orgs].forEach(x => { const r = rf[rk(x)] || ro[rk(x)]; if (r) { x.low_musd = r.realistic_low_musd; x.high_musd = r.realistic_high_musd; } });
document.getElementById("m-range").textContent = `${fmtM(real.realistic_low_musd)} to ${fmtM(real.realistic_high_musd)}`;
document.getElementById("m-extreme").textContent = `${fmtM(real.extreme_low_musd)} to ${fmtM(real.extreme_high_musd)}`;
document.getElementById("t-central").textContent = fmtM(t.central_musd);
document.getElementById("t-range").textContent = `${fmtM(real.realistic_low_musd)}–${fmtM(real.realistic_high_musd)}`;
document.getElementById("t-fam").textContent = families.length;
document.getElementById("t-org").textContent = orgs.length;
document.getElementById("t-doc").textContent = fmtM(t.documented_input_central_musd);
const win = {};
windows.forEach(w => { win[w.organization + "|" + w.family] = w; });
families.forEach(f => {
const w = win[f.organization + "|" + f.family] || {};
f.first = w.first_month || ""; f.last = w.last_month || "";
f.grade = gradeOf(f); f.basis = basisOf(f);
});
const state = { mode: "late", view: "total" };
const redraw = () => drawCurve(cumulative, releases.releases, state.mode, state.view, families);
[["booking", "mode"], ["view", "view"]].forEach(([id, k]) => document.querySelectorAll(`#${id} button`).forEach(b => b.onclick = () => {
document.querySelectorAll(`#${id} button`).forEach(x => x.classList.toggle("on", x === b));
state[k] = b.dataset.v; redraw();
}));
redraw();
drawOrgs(orgs);
drawEvidence(families, t.central_musd);
setupTable(families);
});
function drawCurve(rows, rels, mode, view, families) {
const split = view === "evidence";
// cumulative central cost per evidence grade, each family booked at its first or latest release month
const by = { A: [], B: [], C: [] };
rows.forEach(r => ["A", "B", "C"].forEach(g => by[g].push(families
.filter(f => f.grade === g && (mode === "early" ? f.first : f.last) && (mode === "early" ? f.first : f.last) <= r.month)
.reduce((s, f) => s + f.central_musd, 0))));
const leg = document.getElementById("curvelegend");
leg.style.display = split ? "flex" : "none";
leg.innerHTML = ["A", "B", "C"].map(g => `<span><i style="background:${GRADE[g].color}"></i>${GRADE[g].label}</span>`).join("");
const svg = document.getElementById("curve"); svg.innerHTML = "";
const W = 1000, H = 440, L = 64, R = 96, T = 24, B = 36;
const key = `central_${mode}_musd`;
const ys = rows.map(r => r[key]);
const ymax = 1450;
const X = i => L + (W - L - R) * i / (rows.length - 1);
const Y = v => H - B - (H - T - B) * v / ymax;
const defs = el("defs", {}, svg);
const g = el("linearGradient", { id: "fill", x1: 0, y1: 0, x2: 0, y2: 1 }, defs);
el("stop", { offset: "0", "stop-color": "#93C5FD", "stop-opacity": .75 }, g);
el("stop", { offset: "1", "stop-color": "#93C5FD", "stop-opacity": 0 }, g);
for (let v = 0; v <= 1250; v += 250) {
el("line", { x1: L, x2: W - R + 20, y1: Y(v), y2: Y(v), stroke: C.line }, svg);
el("text", { x: L - 10, y: Y(v) + 4, "text-anchor": "end", fill: C.muted, "font-size": 12 }, svg).textContent = v === 0 ? "$0" : v >= 1000 ? `$${v / 1000}B` : `$${v}M`;
}
rows.forEach((r, i) => { if (r.month.endsWith("-01")) el("text", { x: X(i), y: H - 12, "text-anchor": "middle", fill: C.muted, "font-size": 12 }, svg).textContent = r.month.slice(0, 4); });
const n = ys.length;
// monotone smoothing (Fritsch-Carlson); segs() returns the curve without its starting move
const segs = (v, rev) => {
const d = [], m = new Array(n).fill(0);
for (let i = 0; i < n - 1; i++) d.push(v[i + 1] - v[i]);
for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] > 0 ? 2 / (1 / d[i - 1] + 1 / d[i]) : 0;
m[0] = d[0]; m[n - 1] = d[n - 2];
let p = "";
if (!rev) for (let i = 0; i < n - 1; i++) { const dx = X(i + 1) - X(i);
p += ` C${X(i) + dx / 3},${Y(v[i] + m[i] / 3)} ${X(i + 1) - dx / 3},${Y(v[i + 1] - m[i + 1] / 3)} ${X(i + 1)},${Y(v[i + 1])}`; }
else for (let i = n - 1; i > 0; i--) { const dx = X(i) - X(i - 1);
p += ` C${X(i) - dx / 3},${Y(v[i] - m[i] / 3)} ${X(i - 1) + dx / 3},${Y(v[i - 1] + m[i - 1] / 3)} ${X(i - 1)},${Y(v[i - 1])}`; }
return p;
};
const path = `M${X(0)},${Y(ys[0])}` + segs(ys);
const lo = rows.map(r => r[`realistic_low_${mode}_musd`]), hi = rows.map(r => r[`realistic_high_${mode}_musd`]);
const sA = by.A, sAB = by.A.map((v, i) => v + by.B[i]), sABC = sAB.map((v, i) => v + by.C[i]);
if (split) {
const zero = new Array(n).fill(0);
[[zero, sA, "A"], [sA, sAB, "B"], [sAB, sABC, "C"]].forEach(([a, b, g]) =>
el("path", { d: `M${X(0)},${Y(a[0])}` + segs(a) + ` L${X(n - 1)},${Y(b[n - 1])}` + segs(b, true) + " Z", fill: GRADE[g].color, "fill-opacity": g === "C" ? 1 : .9 }, svg));
[[sA, "A"], [sAB, "B"], [sABC, "C"]].forEach(([v, g]) => {
const mid = v[n - 1] - ({ A: sA[n - 1], B: by.B[n - 1], C: by.C[n - 1] }[g]) / 2;
el("text", { x: X(n - 1) + 12, y: Y(mid) + 4, fill: C.muted, "font-size": 12 }, svg).textContent = `${Math.round(100 * ({ A: sA[n - 1], B: by.B[n - 1], C: by.C[n - 1] }[g]) / sABC[n - 1])}%`;
});
} else {
el("path", { d: `${path} L${X(n - 1)},${Y(0)} L${X(0)},${Y(0)} Z`, fill: "url(#fill)" }, svg);
el("path", { d: `M${X(0)},${Y(lo[0])}` + segs(lo) + ` L${X(n - 1)},${Y(hi[n - 1])}` + segs(hi, true) + " Z", fill: C.mid, "fill-opacity": .22 }, svg);
[lo, hi].forEach(v => el("path", { d: `M${X(0)},${Y(v[0])}` + segs(v), fill: "none", stroke: C.mid, "stroke-width": 1 }, svg));
[lo[n - 1], hi[n - 1]].forEach(v => el("text", { x: X(n - 1) + 12, y: Y(v) + 4, fill: C.muted, "font-size": 12 }, svg).textContent = fmtM(v));
el("text", { x: X(n - 1) + 12, y: Y(hi[n - 1]) - 12, fill: C.muted, "font-size": 11 }, svg).textContent = "realistic range";
}
el("path", { d: path, fill: "none", stroke: C.blue, "stroke-width": 3.2, "stroke-linecap": "round" }, svg);
const idx = Object.fromEntries(rows.map((r, i) => [r.month, i]));
let prev = 0;
NOTABLE.forEach(([mo, label]) => {
const i = idx[mo]; if (i === undefined) return;
const yv = ys[i]; const h = Math.max(yv + 150, prev + 70); prev = h;
el("line", { x1: X(i), x2: X(i), y1: Y(yv), y2: Y(h) + 6, stroke: "#94A3B8", "stroke-width": 1 }, svg);
el("circle", { cx: X(i), cy: Y(yv), r: 4.5, fill: C.deep, stroke: "#fff", "stroke-width": 2 }, svg);
el("text", { x: X(i), y: Y(h), "text-anchor": "middle", fill: C.ink, "font-size": 12.5, "font-weight": 700, stroke: "#fff", "stroke-width": 4, "paint-order": "stroke" }, svg).textContent = label;
});
el("circle", { cx: X(n - 1), cy: Y(ys[n - 1]), r: 6, fill: C.blue, stroke: "#fff", "stroke-width": 2 }, svg);
el("text", { x: X(n - 1) + 12, y: Y(ys[n - 1]) + (split ? -14 : 7), fill: C.deep, "font-size": 22, "font-weight": 700 }, svg).textContent = fmtM(ys[n - 1]);
// hover layer
const cross = el("line", { y1: T, y2: H - B, stroke: C.muted, "stroke-dasharray": "3 3", opacity: 0 }, svg);
const dot = el("circle", { r: 5, fill: C.blue, stroke: "#fff", "stroke-width": 2, opacity: 0 }, svg);
const hit = el("rect", { x: L, y: T, width: W - L - R, height: H - T - B, fill: "transparent" }, svg);
hit.addEventListener("mousemove", ev => {
const p = svg.createSVGPoint(); p.x = ev.clientX; p.y = ev.clientY;
const loc = p.matrixTransform(svg.getScreenCTM().inverse());
const i = Math.max(0, Math.min(n - 1, Math.round((loc.x - L) / (W - L - R) * (n - 1))));
cross.setAttribute("x1", X(i)); cross.setAttribute("x2", X(i)); cross.setAttribute("opacity", 1);
dot.setAttribute("cx", X(i)); dot.setAttribute("cy", Y(ys[i])); dot.setAttribute("opacity", 1);
const month = rows[i].month, inMonth = rels.filter(r => r.month === month).sort((a, b) => b.display_priority - a.display_priority).slice(0, 5);
showTip(`<b>${monthName(month)}</b><br>Cumulative: ${fmtM(ys[i])}<br><span style="opacity:.75">` +
(split ? ["A", "B", "C"].map(g => `${GRADE[g].label}: ${fmtM(by[g][i])}`).join("<br>") : `Realistic range: ${fmtM(lo[i])}–${fmtM(hi[i])}`) + `</span>` + (inMonth.length ? `<br><span style="opacity:.75">Released: ${inMonth.map(r => r.model).join(", ")}</span>` : ""), ev);
});
hit.addEventListener("mouseleave", () => { cross.setAttribute("opacity", 0); dot.setAttribute("opacity", 0); hideTip(); });
}
function drawOrgs(orgs) {
const svg = document.getElementById("orgs"); svg.innerHTML = "";
const sorted = [...orgs].sort((a, b) => b.central_musd - a.central_musd);
const top = sorted.slice(0, 14), rest = sorted.slice(14);
if (rest.length) top.push({ organization: `${rest.length} others`, central_musd: rest.reduce((s, o) => s + o.central_musd, 0), low_musd: null, high_musd: null, others: rest.map(o => o.organization) });
const W = 1000, rowH = 30, L = 150, R = 70, T = 8;
const H = T + rowH * top.length + 26;
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
const xmax = 400;
const X = v => L + (W - L - R) * Math.min(v, xmax) / xmax;
[0, 100, 200, 300, 400].forEach(v => {
el("line", { x1: X(v), x2: X(v), y1: T, y2: H - 22, stroke: C.line }, svg);
el("text", { x: X(v), y: H - 6, "text-anchor": "middle", fill: C.muted, "font-size": 12 }, svg).textContent = v ? `$${v}M` : "$0";
});
top.forEach((o, i) => {
const y = T + i * rowH;
el("text", { x: L - 10, y: y + 19, "text-anchor": "end", fill: C.ink, "font-size": 13.5 }, svg).textContent = o.organization;
if (o.high_musd != null) {
el("line", { x1: X(o.low_musd), x2: X(o.high_musd), y1: y + 15, y2: y + 15, stroke: C.mid, "stroke-width": 1.5 }, svg);
if (o.high_musd > xmax) el("text", { x: X(xmax) + 4, y: y + 19, fill: C.muted, "font-size": 11 }, svg).textContent = `→ ${fmtM(o.high_musd)}`;
}
const bar = el("rect", { x: L, y: y + 6, width: Math.max(2, X(o.central_musd) - L), height: rowH - 12, rx: 4, fill: o.others ? C.light : C.blue }, svg);
el("text", { x: X(o.central_musd) + 6, y: y + 19, fill: C.ink, "font-size": 12.5, "font-weight": 700, stroke: "#fff", "stroke-width": 4, "paint-order": "stroke" }, svg).textContent = fmtM(o.central_musd);
const hit = el("rect", { x: 0, y, width: W, height: rowH, fill: "transparent" }, svg);
hit.addEventListener("mousemove", ev => showTip(o.others ? `<b>${o.organization}</b><br>${fmtM(o.central_musd)} combined<br><span style="opacity:.75">${o.others.join(", ")}</span>`
: `<b>${o.organization}</b><br>Central: ${fmtM(o.central_musd)}<br>Range: ${fmtM(o.low_musd)}–${fmtM(o.high_musd)}<br>From reported compute: ${fmtM(o.documented_input_central_musd)}`, ev));
hit.addEventListener("mouseleave", hideTip);
});
}
function drawEvidence(families, total) {
const sums = { A: 0, B: 0, C: 0 }, counts = { A: 0, B: 0, C: 0 };
families.forEach(f => { sums[f.grade] += f.central_musd; counts[f.grade]++; });
const bar = document.getElementById("evbar"), leg = document.getElementById("evlegend");
["A", "B", "C"].forEach(g => {
const pct = Math.round(100 * sums[g] / total);
const d = document.createElement("div");
d.style.flex = sums[g]; d.style.background = GRADE[g].color; d.style.color = GRADE[g].text;
d.textContent = `${pct}%`;
d.onmousemove = ev => showTip(`<b>${GRADE[g].label}</b><br>${fmtM(sums[g])} across ${counts[g]} families`, ev);
d.onmouseleave = hideTip;
bar.appendChild(d);
leg.insertAdjacentHTML("beforeend", `<span><i style="background:${GRADE[g].color}"></i>${GRADE[g].label}: ${fmtM(sums[g])}, ${counts[g]} families</span>`);
});
}
function setupTable(families) {
const tbody = document.querySelector("#tbl tbody");
let sortK = "central_musd", dir = -1;
const render = () => {
const q = document.getElementById("q").value.toLowerCase(), ev = document.getElementById("ev").value;
const rows = families.filter(f => (!ev || f.grade === ev) && (f.family + " " + f.organization).toLowerCase().includes(q))
.sort((a, b) => { const x = a[sortK] ?? -1, y = b[sortK] ?? -1; return (x > y ? 1 : x < y ? -1 : 0) * dir; });
tbody.innerHTML = rows.map(f => {
const g = GRADE[f.grade];
const rel = f.first ? (f.first === f.last ? monthName(f.first) : `${monthName(f.first)} – ${monthName(f.last)}`) : "";
const src = (f.sources || []).map((s, i) => `<a href="${s}" target="_blank" rel="noopener" title="${s}">[${i + 1}]</a>`).join("");
return `<tr><td>${f.organization}</td><td>${f.family}</td><td>${rel}</td>
<td><span class="pill" style="background:${g.color};color:${g.text}">${g.label}</span></td>
<td>${f.basis}</td>
<td class="num"><b>${Math.round(f.central_musd)}</b><span class="rng">${Math.round(f.low_musd)}–${Math.round(f.high_musd)}</span></td>
<td class="src">${src}</td></tr>`;
}).join("");
};
document.querySelectorAll("#tbl th[data-k]").forEach(th => th.onclick = () => {
const k = th.dataset.k; dir = sortK === k ? -dir : (k === "central_musd" ? -1 : 1); sortK = k; render();
});
document.getElementById("q").oninput = render;
document.getElementById("ev").onchange = render;
render();
}
</script>
</body>
</html>