/* Explorer engine over UI_DATA (weekly org aggregates from hfmlsoc/hub_weekly_snapshots). */
"use strict";
const WEEKS = UI_DATA.weeks;
const W = WEEKS.length;
const parseD = d3.timeParse("%Y-%m-%d");
const DATES = WEEKS.map(parseD);
const fmtWeek = d3.timeFormat("%b %-d, %Y");
// ---------- state ----------
const DEFAULT_I0 = Math.max(0, WEEKS.findIndex(w => w >= "2024-01-01"));
const state = {
view: "line", metric: "volume", type: "models", group: "company",
cat: "all", country: "all", origin: "noext",
topN: 10, allRepos: false, i0: DEFAULT_I0, i1: W - 1,
pins: new Set(), hidden: new Set(),
};
const METRIC_IDX = { volume: 0, likes: 2, downloads: 4 }; // row = [n, n25, likes, d30, dat]
const METRIC_LABEL = { volume: "New repos", likes: "Likes gained", downloads: "Downloads gained" };
const METRIC_STOCK_LABEL = { volume: "Repos", likes: "Total likes", downloads: "Total downloads" };
// ---------- colors ----------
const HERO = { NVidia:"#4a9d79", Alibaba:"#002efe", AllenAI:"#d93672", "Hugging Face":"#f2cd68",
Meta:"#2a406e", Google:"#58a058", IBM:"#2a6494", ByteDance:"#8ae5d9", Microsoft:"#c47026",
Tencent:"#2c68ac", ZAI:"#16161c", Apple:"#b9b9b9" };
const FIXED = { USA:"#2a6494", China:"#e05252", Europe:"#4a9d79", Korea:"#7b5cd6", Japan:"#e8862f",
India:"#f2a93b", Israel:"#2c68ac", UAE:"#8a6d3b", Singapore:"#8ae5d9", Canada:"#c47026",
"Big Tech":"#16161c", Neolabs:"#d93672",
"Original models":"#4a9d79", "Own fine-tunes/quants":"#2a6494", "Others' fine-tunes/quants":"#e8862f" };
// fallback palette deliberately avoids the hero/fixed colors above
const PALETTE = ["#7b5cd6","#e05252","#f2a93b","#5fb3a1","#a05c9e","#7f8c3a","#c9576e","#4d7ea8",
"#8f6b4a","#5b5ea6","#d98a4f","#6aa6e8","#b0468a","#3e8f8f","#d4b13f","#7297b5","#9b6ec8",
"#c96a2e","#628c53","#b55b5b","#4f6d9e","#8c8c46","#cf7fae","#54a3a3"];
const colorCache = {};
function entityColor(name, canonicalRank) {
if (HERO[name]) return HERO[name];
if (FIXED[name]) return FIXED[name];
if (!(name in colorCache)) colorCache[name] = PALETTE[canonicalRank % PALETTE.length];
return colorCache[name];
}
const FLAGS = { USA:"🇺🇸", China:"🇨🇳", Europe:"🇪🇺", Korea:"🇰🇷", Japan:"🇯🇵", India:"🇮🇳",
Israel:"🇮🇱", UAE:"🇦🇪", Singapore:"🇸🇬", Canada:"🇨🇦" };
const CAT_ICON = { "Big Tech":"🏢", Neolabs:"🚀", "Original models":"🧠",
"Own fine-tunes/quants":"🔁", "Others' fine-tunes/quants":"♻️" };
// small entity badge (ringed circle with logo or emoji) appended to an svg group
function svgBadge(g, e, cx, cy, r, defs) {
const b = g.append("g").attr("class", "ebadge").attr("transform", `translate(${cx},${cy})`);
b.append("circle").attr("r", r).attr("fill", "#fff").attr("stroke", e.color).attr("stroke-width", Math.max(2.5, r / 6));
if (e.badge && e.badge.logo) {
const id = "bclip-" + Math.random().toString(36).slice(2, 8);
defs.append("clipPath").attr("id", id).append("circle").attr("r", r - 2.5);
b.append("image").attr("href", e.badge.logo)
.attr("x", -(r - 2.5)).attr("y", -(r - 2.5)).attr("width", (r - 2.5) * 2).attr("height", (r - 2.5) * 2)
.attr("clip-path", `url(#${id})`);
} else {
b.append("text").attr("text-anchor", "middle").attr("dominant-baseline", "central")
.attr("font-size", r).text((e.badge && e.badge.emoji) || "•");
}
return b;
}
const iconHTML = e => e.badge && e.badge.logo
? ``
: e.badge && e.badge.emoji ? `${e.badge.emoji}` : "";
// ---------- accessors (all fields null-safe: null = no data that week) ----------
const nsub = (x, y) => x == null ? null : x - (y || 0);
const nadd = (x, y) => x == null && y == null ? null : (x || 0) + (y || 0);
function sub(a, b) { return a.map((x, i) => nsub(x, b[i])); }
function add(a, b) { return a.map((x, i) => nadd(x, b[i])); }
const ZERO = [null,null,null,null,null];
function modelRow(org, w, origin) {
const m = org.m[w], me = org.me[w], ms = org.ms[w];
if (origin === "noext") return sub(m, me); // own repos minus derivatives of OTHER orgs' models
if (origin === "orig") return sub(sub(m, me), ms);
if (origin === "anyderiv") return add(me, ms);
if (origin === "ext") return me;
return m;
}
function orgValue(org, w, type, metric, origin) {
const idx = METRIC_IDX[metric];
if (type === "models") return modelRow(org, w, origin)[idx];
if (type === "datasets") return org.d[w][idx];
if (type === "spaces") return org.s[w][idx];
// "all" is strict: if any expected component is missing, the week is missing —
// otherwise mixed-validity weeks would render as huge fake dips
const m = modelRow(org, w, origin)[idx], dd = org.d[w][idx];
if (m == null || dd == null) return null;
if (metric === "downloads") return m + dd; // spaces have no downloads
const s = org.s[w][idx];
return s == null ? null : m + dd + s;
}
// ---------- entity building ----------
const ECO_TYPES = new Set(["deriv_eco", "demo_eco", "everything"]);
function filteredOrgs() {
return UI_DATA.orgs.filter(o =>
(state.cat === "all" || o.g === state.cat) &&
(state.country === "all" || o.n === state.country));
}
function filteredCompanies() {
return Object.keys(UI_DATA.companies).filter(c => {
const m = UI_DATA.companies[c];
return (state.cat === "all" || m.cat === state.cat) &&
(state.country === "all" || m.country === state.country);
});
}
// eco row per company/week: [derivCount, derivLikes, derivD30, derivDatCum, demoSpaceCount, demoSpaceLikes]
function ecoValue(c, w, type, metric) {
const r = UI_DATA.eco[c][w];
if (type === "deriv_eco")
return metric === "volume" ? r[0] : metric === "likes" ? r[1] : (r[3] ?? null);
if (type === "demo_eco")
return metric === "volume" ? r[4] : metric === "likes" ? r[5] : null;
// everything = deriv + demo spaces, strict (downloads: derivatives only)
if (metric === "downloads") return r[3] ?? null;
const dv = metric === "volume" ? r[0] : r[1];
const sv = metric === "volume" ? r[4] : r[5];
return dv == null || sv == null ? null : dv + sv;
}
function entitySeries() {
// returns [{name, V:[..W], badge:{logo|emoji}}]
if (ECO_TYPES.has(state.type) && state.group !== "origin") {
const comps = filteredCompanies();
const orgsByComp = d3.group(filteredOrgs(), o => o.c);
const compV = c => d3.range(W).map(w => {
let v = ecoValue(c, w, state.type, state.metric);
if (state.type === "everything") {
if (v == null) return null; // strict: no partial composites
for (const org of (orgsByComp.get(c) || [])) {
const ov = orgValue(org, w, "all", state.metric, "all");
if (ov == null) return null;
v += ov;
}
}
return v;
});
const key = state.group === "company" ? (c => c)
: state.group === "country" ? (c => UI_DATA.companies[c].country)
: (c => UI_DATA.companies[c].cat === "bigtech" ? "Big Tech" : "Neolabs");
const groups = d3.group(comps, key);
return [...groups].map(([name, members]) => {
const Vs = members.map(compV);
const V = d3.range(W).map(w => {
let s = 0, any = false;
for (const Vc of Vs) {
if (Vc[w] == null) return null;
s += Vc[w]; any = true;
}
return any ? s : null;
});
const badge = state.group === "company" ? { logo: UI_DATA.companies[name].logo }
: state.group === "country" ? { emoji: FLAGS[name] || "🌍" }
: { emoji: CAT_ICON[name] };
return { name, V, badge };
});
}
const orgs = filteredOrgs();
if (state.group === "origin") {
const kinds = [["Original models","orig"], ["Own fine-tunes/quants","self_"], ["Others' fine-tunes/quants","ext"]];
return kinds.map(([name, o]) => {
const V = d3.range(W).map(w => {
let s = 0, any = false;
for (const org of orgs) {
const r = o === "self_" ? org.ms[w] : modelRow(org, w, o);
const v = r[METRIC_IDX[state.metric]];
if (v == null) return null;
s += v; any = true;
}
return any ? s : null;
});
return { name, V, badge: { emoji: CAT_ICON[name] } };
});
}
const key = state.group === "company" ? (o => o.c)
: state.group === "country" ? (o => o.n)
: (o => o.g === "bigtech" ? "Big Tech" : "Neolabs");
const groups = d3.group(orgs, key);
return [...groups].map(([name, members]) => {
const V = d3.range(W).map(w => {
// strict: a sum missing any member is not the real sum
let s = 0, any = false;
for (const org of members) {
const v = orgValue(org, w, state.type, state.metric, state.origin);
if (v == null) return null;
s += v; any = true;
}
return any ? s : null;
});
const badge = state.group === "company" ? { logo: UI_DATA.companies[name].logo }
: state.group === "country" ? { emoji: FLAGS[name] || "🌍" }
: { emoji: CAT_ICON[name] };
return { name, V, badge };
});
}
function rebasedValue(e, w, from) { // value accumulated within [from||i0, w]
if (state.allRepos) return e.V[w];
let b = null;
for (let i = from ?? state.i0; i <= w; i++) if (e.V[i] != null) { b = e.V[i]; break; }
if (b == null || e.V[w] == null) return null;
return e.V[w] - b;
}
function weeklyDelta(e, w) {
if (w <= 0 || e.V[w] == null || e.V[w-1] == null) return null;
return e.V[w] - e.V[w-1];
}
function visibleEntities(atWeek, from) {
const all = entitySeries();
// canonical rank (stable colors): by full-range end value
const canon = [...all].sort((a, b) => (b.V[W-1] ?? 0) - (a.V[W-1] ?? 0));
canon.forEach((e, i) => e.canon = i);
const w = atWeek ?? state.i1;
all.forEach(e => e.val = rebasedValue(e, w, from) ?? 0);
const ranked = [...all].filter(e => !state.hidden.has(e.name)).sort((a, b) => b.val - a.val);
const top = ranked.slice(0, state.topN);
const pinned = ranked.filter(e => state.pins.has(e.name) && !top.includes(e));
const shown = [...top, ...pinned].sort((a, b) => b.val - a.val);
shown.forEach(e => e.color = entityColor(e.name, e.canon));
all.forEach(e => e.color = entityColor(e.name, e.canon));
return { shown, all };
}
// ---------- formatting ----------
const fmtVal = v => v == null ? "–"
: Math.abs(v) >= 1e9 ? (v/1e9).toFixed(1) + "B"
: Math.abs(v) >= 1e6 ? (v/1e6).toFixed(1) + "M"
: Math.abs(v) >= 1e4 ? Math.round(v/1e3) + "k"
: Math.abs(v) >= 1e3 ? (v/1e3).toFixed(1) + "k"
: String(Math.round(v));
// ---------- svg scaffolding ----------
const CW = 1352;
const M = { top: 30, right: 96, bottom: 60, left: 110 };
const chartDiv = d3.select("#chart");
const tooltip = document.getElementById("tooltip");
function baseSvg(height) {
chartDiv.selectAll("svg").remove();
return chartDiv.append("svg").attr("viewBox", `0 0 ${CW} ${height}`)
.attr("preserveAspectRatio", "xMidYMid meet");
}
// ---------- line view ----------
// line chart floors at zero: net deletions show as a flat line, never below the axis
function lineVal(e, w) {
const v = rebasedValue(e, w);
return v == null ? null : Math.max(0, v);
}
function renderLine(endW) {
const H = 980, IW = CW - M.left - M.right, IH = H - M.top - M.bottom;
const { shown } = visibleEntities(endW); // ranking follows the animated frame
const svg = baseSvg(H);
const g = svg.append("g").attr("transform", `translate(${M.left},${M.top})`);
// clamp the x-domain to weeks where at least one entity has data
// (e.g. likes/downloads only exist from the snapshot era onward)
let lo = state.i1, hi = state.i0;
for (let w = state.i0; w <= state.i1; w++)
if (shown.some(e => e.V[w] != null)) { lo = Math.min(lo, w); hi = Math.max(hi, w); }
if (lo > hi) { lo = state.i0; hi = state.i1; }
const animating = endW != null && endW < hi;
const drawHi = endW != null ? Math.max(lo, Math.min(endW, hi)) : hi;
const xd = [DATES[lo], DATES[hi]];
const x = d3.scaleTime().domain(xd).range([0, IW]);
const maxV = d3.max(shown, e => d3.max(d3.range(state.i0, state.i1 + 1), w => lineVal(e, w))) || 1;
const y = d3.scaleLinear().domain([0, maxV * 1.04]).range([IH, 0]);
const yTicks = y.ticks(8);
g.selectAll(".gridline").data(yTicks.filter(t => t > 0)).join("line").attr("class", "gridline")
.attr("x1", 0).attr("x2", IW + 60).attr("y1", d => y(d)).attr("y2", d => y(d));
g.selectAll(".tick-label-y").data(yTicks).join("text").attr("class", "tick-label-y")
.attr("x", -14).attr("y", d => y(d)).attr("text-anchor", "end").attr("dominant-baseline", "middle")
.text(fmtVal);
svg.append("line").attr("class", "domain-line").attr("x1", 46).attr("x2", 46)
.attr("y1", M.top - 10).attr("y2", M.top + IH);
g.append("line").attr("class", "domain-line").attr("x1", 0).attr("x2", IW + 60)
.attr("y1", IH).attr("y2", IH);
svg.append("text").attr("class", "axis-title")
.attr("transform", `translate(20,${M.top + IH / 2}) rotate(-90)`).attr("text-anchor", "middle")
.text(state.allRepos && state.metric === "volume" ? METRIC_STOCK_LABEL[state.metric] : METRIC_LABEL[state.metric]);
const spanDays = (xd[1] - xd[0]) / 864e5;
const interval = spanDays > 450 ? d3.timeMonth.every(3) : spanDays > 130 ? d3.timeMonth.every(1) : d3.timeWeek.every(2);
g.selectAll(".tick-label-x").data(x.ticks(interval)).join("text").attr("class", "tick-label-x")
.attr("x", d => x(d)).attr("y", IH + 30).attr("text-anchor", "middle")
.text(d3.timeFormat(spanDays > 130 ? "%b %Y" : "%b %-d"));
const idxs = d3.range(lo, drawHi + 1);
for (const e of shown) {
const pts = idxs.map(w => [x(DATES[w]), lineVal(e, w)]).filter(p => p[1] != null)
.map(p => [p[0], y(p[1])]);
g.append("path").attr("d", d3.line()(pts))
.attr("fill", "none").attr("stroke", e.color).attr("stroke-width", 4)
.attr("stroke-linecap", "round").attr("stroke-linejoin", "round")
.attr("stroke-dasharray", "10 8 0.1 8");
}
drawBadges(svg, g, shown, e => lineVal(e, drawHi), y, IW, IH,
endW != null ? x(DATES[drawHi]) : null);
if (animating || endW != null) {
svg.append("text").attr("x", CW - M.right - 20).attr("y", M.top + IH - 24)
.attr("text-anchor", "end").attr("font-family", "Poppins, sans-serif")
.attr("font-weight", 800).attr("font-size", 44).attr("fill", "#d8d8d8")
.text(d3.timeFormat("%b %Y")(DATES[drawHi]));
const sc = document.getElementById("racescrub");
sc.min = lo; sc.max = hi; sc.value = drawHi;
document.getElementById("raceweek").textContent = fmtWeek(DATES[drawHi]);
}
drawLegend(shown);
if (!animating) hoverLayer(svg, g, x, IW, IH, idxs, shown, w => e => lineVal(e, w));
}
function drawBadges(svg, g, shown, valFn, y, IW, IH, atX) {
const BR = 27, GAP = 36;
const bx0 = (atX != null ? atX : IW) + BR + 8;
const defs = svg.append("defs");
const placed = shown.map(e => ({ e, want: y(Math.max(0, valFn(e) ?? 0)) })).sort((a, b) => a.want - b.want);
let prev = -Infinity;
for (const p of placed) { p.by = Math.max(p.want, prev + GAP); prev = p.by; }
for (let i = placed.length - 1; i >= 0; i--) {
const lim = i === placed.length - 1 ? IH + BR / 2 : placed[i + 1].by - GAP;
placed[i].by = Math.min(placed[i].by, lim);
}
for (const p of [...placed].sort((a, b) => (valFn(a.e) ?? 0) - (valFn(b.e) ?? 0))) {
const e = p.e, bx = bx0;
const badge = g.append("g").attr("transform", `translate(${bx},${p.by})`);
badge.append("circle").attr("r", BR).attr("fill", "#fff").attr("stroke", e.color).attr("stroke-width", 4.5);
if (e.badge.logo) {
const id = "clip-" + e.name.replace(/[^a-z0-9]/gi, "");
defs.append("clipPath").attr("id", id).append("circle").attr("r", BR - 4);
badge.append("image").attr("href", e.badge.logo)
.attr("x", -(BR - 4)).attr("y", -(BR - 4)).attr("width", (BR - 4) * 2).attr("height", (BR - 4) * 2)
.attr("clip-path", `url(#${id})`);
} else {
badge.append("text").attr("text-anchor", "middle").attr("dominant-baseline", "central")
.attr("font-size", 26).text(e.badge.emoji || "•");
}
}
}
// ---------- bars view = race frame (playable via the controls under the timeline) ----------
const MLW = 190; // left margin for horizontal-bar layouts (long entity names)
// ---------- race view ----------
let raceTimer = null, raceWeek = null;
function racePeriodStart(w) {
const mode = document.getElementById("period").value;
if (mode === "cum") return state.i0;
const d = DATES[w];
const boundary = mode === "year" ? new Date(d.getFullYear(), 0, 1)
: mode === "quarter" ? new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1)
: new Date(d.getFullYear(), d.getMonth(), 1);
let s = state.i0;
for (let i = w; i >= state.i0; i--) if (DATES[i] < boundary) { s = i; break; }
return s;
}
function racePeriodLabel(w) {
const mode = document.getElementById("period").value;
const d = DATES[w];
if (mode === "year") return String(d.getFullYear());
if (mode === "quarter") return "Q" + (Math.floor(d.getMonth() / 3) + 1) + " " + d.getFullYear();
if (mode === "month") return d3.timeFormat("%b %Y")(d);
return d3.timeFormat("%b %Y")(d);
}
function renderRace(frameWeek) {
const w = frameWeek ?? raceWeek ?? state.i0 + 1;
raceWeek = Math.min(Math.max(w, state.i0), state.i1);
const p0 = racePeriodStart(raceWeek);
const { shown } = visibleEntities(raceWeek, p0);
const N = shown.length;
const ROW = 56, IW = CW - MLW - M.right;
const H = M.top + Math.max(N, state.topN) * ROW + 46;
let svg = chartDiv.select("svg.race");
let fresh = false;
if (svg.empty() || +svg.attr("data-n") !== N) {
chartDiv.selectAll("svg").remove();
svg = chartDiv.append("svg").attr("class", "race").attr("data-n", N)
.attr("viewBox", `0 0 ${CW} ${H}`).attr("preserveAspectRatio", "xMidYMid meet");
fresh = true;
}
const dur = raceTimer ? +document.getElementById("speed").value : 0;
const maxV = d3.max(shown, e => e.val) || 1;
const x = d3.scaleLinear().domain([0, maxV * 1.08]).range([0, IW]);
let g = svg.select("g.main");
if (g.empty()) g = svg.append("g").attr("class", "main").attr("transform", `translate(${MLW},${M.top})`);
const t = svg.transition().duration(dur).ease(d3.easeLinear);
const bars = g.selectAll("g.bar").data(shown, e => e.name);
const enter = bars.enter().append("g").attr("class", "bar")
.attr("transform", (e, i) => `translate(0,${i * ROW})`).attr("opacity", 0);
enter.append("rect").attr("x", 0).attr("y", ROW/2 - 15).attr("height", 30).attr("rx", 5)
.attr("fill", e => e.color).attr("width", 2);
enter.append("text").attr("class", "nm").attr("x", -14).attr("y", ROW/2).attr("text-anchor", "end")
.attr("dominant-baseline", "central").attr("font-size", 15).attr("font-weight", 700).attr("fill", "#222")
.text(e => e.name);
enter.append("text").attr("class", "vl").attr("y", ROW/2)
.attr("dominant-baseline", "central").attr("font-size", 15).attr("font-weight", 700).attr("fill", "#444");
let defs = svg.select("defs");
if (defs.empty()) defs = svg.append("defs");
enter.each(function(e) {
svgBadge(d3.select(this), e, 0, ROW/2, 17, defs).attr("class", "ebadge lg");
});
const all = enter.merge(bars);
all.transition(t)
.attr("opacity", 1)
.attr("transform", (e, i) => `translate(0,${i * ROW})`);
all.select("rect").transition(t).attr("width", e => Math.max(2, x(e.val)));
all.select("text.vl").transition(t)
.attr("x", e => Math.max(2, x(e.val)) + 52)
.textTween(function(e) {
const from = this._v ?? 0, to = e.val; this._v = to;
const i = d3.interpolateNumber(from, to);
return tt => fmtVal(i(tt));
});
all.select("g.ebadge").transition(t)
.attr("transform", e => `translate(${Math.max(2, x(e.val)) + 25},${ROW/2})`);
bars.exit().transition(t).attr("opacity", 0).remove();
let wk = svg.select("text.wk");
if (wk.empty()) wk = svg.append("text").attr("class", "wk")
.attr("x", CW - 40).attr("y", H - 30).attr("text-anchor", "end")
.attr("font-family", "Poppins, sans-serif").attr("font-weight", 800)
.attr("font-size", 44).attr("fill", "#d8d8d8");
wk.text(d3.timeFormat("%b %Y")(DATES[raceWeek]));
let pl = svg.select("text.pl");
if (pl.empty()) pl = svg.append("text").attr("class", "pl")
.attr("x", CW - 40).attr("y", H - 84).attr("text-anchor", "end")
.attr("font-family", "Poppins, sans-serif").attr("font-weight", 800)
.attr("font-size", 26).attr("fill", "#e8b6c8");
pl.text(document.getElementById("period").value === "cum" ? "" : racePeriodLabel(raceWeek));
document.getElementById("raceweek").textContent = fmtWeek(DATES[raceWeek]);
const sc = document.getElementById("racescrub");
sc.min = state.i0; sc.max = state.i1; sc.value = raceWeek;
drawLegend([]);
}
// ---------- smooth line race (aiworld-style): rAF-interpolated, sliding trailing window ----------
let animRAF = null, animState = null;
function lineRaceStop() {
if (animRAF) cancelAnimationFrame(animRAF);
animRAF = null;
}
function lineRaceReset() { lineRaceStop(); animState = null; }
function fracDate(f) {
const i = Math.max(0, Math.min(W - 2, Math.floor(f)));
const t = Math.max(0, Math.min(1, f - i));
return new Date(+DATES[i] + t * (DATES[i + 1] - DATES[i]));
}
function lineRaceBuild(fromF) {
const { shown: cast } = visibleEntities();
let lo = state.i1, hi = state.i0;
for (let w = state.i0; w <= state.i1; w++)
if (cast.some(e => e.V[w] != null)) { lo = Math.min(lo, w); hi = Math.max(hi, w); }
if (lo >= hi) return null;
const H = 980, IW = CW - M.left - M.right, IH = H - M.top - M.bottom;
const svg = baseSvg(H);
const defs = svg.append("defs");
const g = svg.append("g").attr("transform", `translate(${M.left},${M.top})`);
const gGrid = g.append("g"), gX = g.append("g"), gLines = g.append("g"), gBadges = g.append("g");
g.append("line").attr("class", "domain-line").attr("x1", 0).attr("x2", IW + 60)
.attr("y1", IH).attr("y2", IH);
svg.append("text").attr("class", "axis-title")
.attr("transform", `translate(20,${M.top + IH / 2}) rotate(-90)`).attr("text-anchor", "middle")
.text(state.allRepos ? METRIC_STOCK_LABEL[state.metric] : METRIC_LABEL[state.metric]);
const ticker = svg.append("text").attr("x", CW - M.right - 24).attr("y", M.top + IH - 28)
.attr("text-anchor", "end").attr("font-family", "Poppins, sans-serif")
.attr("font-weight", 800).attr("font-size", 46).attr("fill", "#e3e3e3");
const series = cast.map(e => ({
e, vals: d3.range(lo, hi + 1).map(w => ({ w, v: lineVal(e, w) })).filter(p => p.v != null),
}));
const paths = new Map(), badges = new Map();
const BR = 27;
for (const e of cast) {
paths.set(e.name, gLines.append("path").attr("fill", "none").attr("stroke", e.color)
.attr("stroke-width", 4).attr("stroke-linecap", "round").attr("stroke-linejoin", "round")
.attr("stroke-dasharray", "10 8 0.1 8"));
const b = gBadges.append("g");
b.append("circle").attr("r", BR).attr("fill", "#fff").attr("stroke", e.color).attr("stroke-width", 4.5);
if (e.badge.logo) {
const id = "lr-" + Math.random().toString(36).slice(2, 8);
defs.append("clipPath").attr("id", id).append("circle").attr("r", BR - 4);
b.append("image").attr("href", e.badge.logo).attr("x", -(BR - 4)).attr("y", -(BR - 4))
.attr("width", (BR - 4) * 2).attr("height", (BR - 4) * 2).attr("clip-path", `url(#${id})`);
} else {
b.append("text").attr("text-anchor", "middle").attr("dominant-baseline", "central")
.attr("font-size", 26).text(e.badge.emoji || "•");
}
badges.set(e.name, b);
}
drawLegend(cast);
const TRAIL = 44; // ~10-month sliding window
let yMaxCur = null;
const interp = (vals, f) => {
if (!vals.length || f < vals[0].w) return null;
let prev = null;
for (const p of vals) {
if (p.w <= f) { prev = p; continue; }
if (!prev) return null;
const t = (f - prev.w) / (p.w - prev.w);
return prev.v + t * (p.v - prev.v);
}
return prev ? prev.v : null;
};
function update(f) {
f = Math.max(lo, Math.min(f, hi));
const left = Math.max(lo, f - TRAIL), right = Math.max(f, lo + TRAIL);
const x = d3.scaleTime().domain([fracDate(left), fracDate(right)]).range([0, IW]);
let tMax = 1;
for (const s of series) for (const p of s.vals)
if (p.w >= left - 0.01 && p.w <= f) tMax = Math.max(tMax, p.v);
yMaxCur = yMaxCur == null ? tMax * 1.06 : yMaxCur + (tMax * 1.06 - yMaxCur) * 0.10;
const y = d3.scaleLinear().domain([0, Math.max(yMaxCur, 1)]).range([IH, 0]);
const yt = y.ticks(8);
gGrid.selectAll("line").data(yt).join("line").attr("class", "gridline")
.attr("x1", 0).attr("x2", IW + 60).attr("y1", d => y(d)).attr("y2", d => y(d));
gGrid.selectAll("text").data(yt).join("text").attr("class", "tick-label-y")
.attr("x", -14).attr("y", d => y(d)).attr("text-anchor", "end")
.attr("dominant-baseline", "middle").text(fmtVal);
gX.selectAll("text").data(x.ticks(6)).join("text").attr("class", "tick-label-x")
.attr("x", d => x(d)).attr("y", IH + 30).attr("text-anchor", "middle")
.text(d3.timeFormat("%b %Y"));
const ends = [];
for (const s of series) {
const pts = [];
for (const p of s.vals) {
if (p.w > f) break;
if (p.w >= left - 4) pts.push([x(fracDate(p.w)), y(p.v)]);
}
const iv = interp(s.vals, f);
if (iv != null) pts.push([x(fracDate(f)), y(iv)]);
paths.get(s.e.name).attr("d", pts.length > 1 ? d3.line()(pts) : null);
ends.push({ name: s.e.name, want: iv != null ? y(iv) : IH });
}
ends.sort((a, b) => a.want - b.want);
let prev = -Infinity;
for (const p of ends) { p.by = Math.max(p.want, prev + 36); prev = p.by; }
for (let i = ends.length - 1; i >= 0; i--) {
const lim = i === ends.length - 1 ? IH + 14 : ends[i + 1].by - 36;
ends[i].by = Math.min(ends[i].by, lim);
}
const bx = Math.min(x(fracDate(f)) + BR + 8, IW + BR + 8);
for (const p of ends) badges.get(p.name).attr("transform", `translate(${bx},${p.by})`);
ticker.text(d3.timeFormat("%b %-d, %Y")(fracDate(f)));
const sc = document.getElementById("racescrub");
sc.min = lo; sc.max = hi; sc.value = Math.round(f);
document.getElementById("raceweek").textContent = fmtWeek(fracDate(f));
}
return (animState = { update, lo, hi, f: Math.max(fromF ?? lo, lo) });
}
function lineRacePlay() {
const st = animState ?? lineRaceBuild(raceWeek);
if (!st) return;
if (st.f >= st.hi) st.f = st.lo;
document.getElementById("play").textContent = "⏸";
let last = performance.now();
const tick = now => {
const wkPerSec = 2700 / (+document.getElementById("speed").value); // 1× ≈ 6 wk/s
st.f = Math.min(st.hi, st.f + ((now - last) / 1000) * wkPerSec);
last = now;
st.update(st.f);
raceWeek = Math.round(st.f);
if (st.f >= st.hi) { racePause(); return; }
animRAF = requestAnimationFrame(tick);
};
animRAF = requestAnimationFrame(tick);
}
function renderFrame(w) {
if (state.view === "line") {
raceWeek = w;
const st = animState ?? lineRaceBuild(w);
if (st) { st.f = Math.max(st.lo, Math.min(w, st.hi)); st.update(st.f); }
} else renderRace(w);
}
function racePlay() {
if (raceTimer || animRAF) return;
if (state.view === "line") { lineRacePlay(); return; }
document.getElementById("play").textContent = "⏸";
const ents = entitySeries();
const valid = w => ents.some(e => e.V[w] != null); // skip broken / no-data weeks
if (raceWeek == null || raceWeek >= state.i1) raceWeek = state.i0;
const step = () => {
let next = raceWeek + 1;
while (next <= state.i1 && !valid(next)) next += 1;
if (next > state.i1) { racePause(); return; }
raceWeek = next;
renderFrame(raceWeek);
raceTimer = setTimeout(step, +document.getElementById("speed").value);
};
raceTimer = setTimeout(step, 50);
}
function racePause() {
if (raceTimer) clearTimeout(raceTimer);
raceTimer = null;
lineRaceStop();
document.getElementById("play").textContent = "▶";
}
// ---------- weekly heatmap ----------
function renderWeekly() {
const { shown } = visibleEntities();
// keep only weeks where at least one shown entity has data; deltas run
// between consecutive KEPT columns (a column after a gap shows the catch-up)
const kept = d3.range(Math.max(state.i0, 0), state.i1 + 1)
.filter(w => shown.some(e => e.V[w] != null));
const idxs = kept.slice(1);
const prevOf = {}; kept.slice(1).forEach((w, i) => prevOf[w] = kept[i]);
const delta = (e, w) => (e.V[w] == null || e.V[prevOf[w]] == null) ? null : e.V[w] - e.V[prevOf[w]];
const ROW = 36, IW = CW - MLW - M.right + 60;
const H = M.top + shown.length * ROW + 70;
const svg = baseSvg(H);
const g = svg.append("g").attr("transform", `translate(${MLW},${M.top})`);
const cw = IW / Math.max(idxs.length, 1);
const maxD = d3.max(shown, e => d3.max(idxs, w => delta(e, w))) || 1;
const color = v => v == null ? "#f7f7f7"
: v < 0 ? "#dde6f2"
: d3.interpolateRgb("#f0f6f3", "#1d6b4c")(Math.sqrt(v / maxD));
const hdefs = svg.append("defs");
shown.forEach((e, r) => {
g.append("text").attr("x", -14).attr("y", r * ROW + ROW/2).attr("text-anchor", "end")
.attr("dominant-baseline", "central").attr("font-size", 14).attr("font-weight", 700)
.attr("fill", "#222").text(e.name);
svgBadge(g, e, -MLW + 26, r * ROW + ROW/2, 12, hdefs);
idxs.forEach((w, ci) => {
const span = Math.round((DATES[w] - DATES[prevOf[w]]) / 6048e5);
g.append("rect").attr("x", ci * cw + 1).attr("y", r * ROW + 2)
.attr("width", cw - 2).attr("height", ROW - 4).attr("rx", 3)
.attr("fill", color(delta(e, w)))
.on("mousemove", ev => {
const sp = span > 1 ? ` (${span}-week span)` : "";
tooltip.innerHTML = `