Spaces:
Running
Running
Download index.html from thomwolf/hub-dollars: direct link, hf CLI and curl.
- Browser
- Download file 26.5 kB
-
https://huggingface.co/spaces/thomwolf/hub-dollars/resolve/main/index.html
- Command line
-
hf download hf://spaces/thomwolf/hub-dollars/index.html
-
curl -L -o index.html https://huggingface.co/spaces/thomwolf/hub-dollars/resolve/main/index.html
26.5 kB
| <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> | |