/* 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 = `
${fmtWeek(DATES[w])}${sp}
${e.name}+${fmtVal(delta(e, w) ?? 0)}
`; tooltip.style.display = "block"; const host = document.getElementById("chart").getBoundingClientRect(); tooltip.style.left = (ev.clientX - host.left + 16) + "px"; tooltip.style.top = (ev.clientY - host.top - 20) + "px"; }) .on("mouseleave", () => tooltip.style.display = "none"); }); }); const months = d3.scaleTime().domain([DATES[idxs[0]], DATES[idxs[idxs.length-1]]]).range([0, IW]) .ticks(d3.timeMonth.every((DATES[state.i1] - DATES[state.i0])/864e5 > 450 ? 3 : 1)); months.forEach(m => { const ci = d3.bisectLeft(idxs.map(w => +DATES[w]), +m); if (ci >= idxs.length) return; g.append("text").attr("class", "tick-label-x").attr("x", ci * cw).attr("y", shown.length * ROW + 26) .attr("font-size", 14).text(d3.timeFormat("%b %Y")(m)); }); // legend ramp const lg = g.append("g").attr("transform", `translate(${IW - 220},${shown.length * ROW + 40})`); d3.range(0, 1.01, 0.125).forEach((s, i) => lg.append("rect") .attr("x", i * 20).attr("width", 20).attr("height", 10).attr("fill", d3.interpolateRgb("#f0f6f3", "#1d6b4c")(s))); lg.append("text").attr("x", -8).attr("y", 9).attr("text-anchor", "end").attr("font-size", 12).attr("fill", "#666").text("0"); lg.append("text").attr("x", 188).attr("y", 9).attr("font-size", 12).attr("fill", "#666").text("+" + fmtVal(maxD) + "/wk"); drawLegend([]); } // ---------- periods view: small multiples of per-period additions ---------- // answers "who stopped open-sourcing": one mini column chart per entity, // shared y-scale, one bar per calendar period function renderPeriods() { const { shown } = visibleEntities(); const plen = document.getElementById("plen").value; let bounds = []; // [label, startDate, endDate) const d0 = DATES[state.i0], d1 = DATES[state.i1]; let y = d0.getFullYear(), q = Math.floor(d0.getMonth() / 3); const step = plen === "year" ? 12 : plen === "half" ? 6 : 3; let cur = new Date(y, plen === "year" ? 0 : Math.floor(d0.getMonth() / step) * step, 1); while (cur <= d1) { const nxt = new Date(cur.getFullYear(), cur.getMonth() + step, 1); const lbl = plen === "year" ? String(cur.getFullYear()) : plen === "half" ? "H" + (cur.getMonth() < 6 ? 1 : 2) + " " + String(cur.getFullYear()).slice(2) : "Q" + (Math.floor(cur.getMonth() / 3) + 1) + " " + String(cur.getFullYear()).slice(2); bounds.push([lbl, cur, nxt]); cur = nxt; } const lastNonNull = (e, limIdx, lo) => { for (let i = Math.min(limIdx, state.i1); i >= lo; i--) if (e.V[i] != null) return e.V[i]; return null; }; const idxOfDate = dt => d3.bisectRight(DATES.map(d => +d), +dt) - 1; const firstNonNull = (e, lo, hiIdx) => { for (let i = Math.max(lo, state.i0); i <= Math.min(hiIdx, state.i1); i++) if (e.V[i] != null) return e.V[i]; return null; }; let vals = shown.map(e => bounds.map(([lbl, s, x]) => { const aIdx = Math.max(idxOfDate(s), state.i0 - 1); const endIdx = Math.min(idxOfDate(new Date(+x - 1)), state.i1); const b = lastNonNull(e, endIdx, 0); if (b == null) return null; let a = lastNonNull(e, aIdx, 0), partial = false; if (a == null) { a = firstNonNull(e, aIdx + 1, endIdx); partial = true; } // data starts mid-period return a == null ? null : { v: b - a, partial }; })); // hide periods with no data for anybody (e.g. likes don't exist before mid-2024) const keepCol = bounds.map((_, bi) => vals.some(row => row[bi] != null)); bounds = bounds.filter((_, bi) => keepCol[bi]); vals = vals.map(row => row.filter((_, bi) => keepCol[bi])); const partialStart = bounds.length && idxOfDate(bounds[0][1]) < state.i0 - 1; // shared scale, but don't let one outlier bar flatten every other panel const flat = vals.flat().filter(c => c != null && c.v > 0).map(c => c.v).sort((a, b) => b - a); let maxV = flat[0] || 1; if (flat.length > 1 && flat[0] > flat[1] * 2.5) maxV = flat[1] * 1.15; const COLS = Math.min(shown.length, 5); const PW = (CW - 60) / COLS, PH = 210, PAD = 26; const rows = Math.ceil(shown.length / COLS); const H = M.top + rows * (PH + 64) + 10; const svg = baseSvg(H); const defs = svg.append("defs"); const nowPartial = bounds.length && bounds[bounds.length - 1][2] > DATES[state.i1]; shown.forEach((e, i) => { const px = 30 + (i % COLS) * PW, py = M.top + Math.floor(i / COLS) * (PH + 64); const g = svg.append("g").attr("transform", `translate(${px},${py})`); svgBadge(g, e, 14, 6, 13, defs); g.append("text").attr("x", 34).attr("y", 6).attr("dominant-baseline", "central") .attr("font-size", 15).attr("font-weight", 700).attr("fill", "#222") .text(e.name.length > 22 ? e.name.slice(0, 21) + "…" : e.name); const bw = Math.min(46, (PW - PAD * 2) / bounds.length - 8); const xs = d3.scalePoint().domain(bounds.map(b => b[0])).range([PAD + bw/2, PW - PAD - bw/2]); const ys = v => (PH - 46) * (1 - v / maxV); bounds.forEach(([lbl], bi) => { const cell = vals[i][bi]; const bx = xs(lbl) - bw / 2, baseY = 26 + (PH - 46); if (cell == null) { g.append("text").attr("x", xs(lbl)).attr("y", baseY - 6).attr("text-anchor", "middle") .attr("font-size", 11).attr("fill", "#ccc").text("–"); } else { const v = cell.v; const clipped = v > maxV; const h = Math.max(2, (PH - 46) * Math.min(Math.max(v, 0), maxV) / maxV); const partial = cell.partial || (bi === bounds.length - 1 && nowPartial) || (bi === 0 && partialStart); g.append("rect").attr("x", bx).attr("y", baseY - h).attr("width", bw).attr("height", h) .attr("rx", 4).attr("fill", e.color) .attr("opacity", partial ? 0.45 : (v < 0 ? 0.25 : 1)); if (clipped) // clipped-bar notch g.append("rect").attr("x", bx - 2).attr("y", baseY - h + 8).attr("width", bw + 4) .attr("height", 3.5).attr("fill", "#fff").attr("transform", `rotate(-6 ${xs(lbl)} ${baseY - h + 10})`); g.append("text").attr("x", xs(lbl)).attr("y", baseY - h - 7).attr("text-anchor", "middle") .attr("font-size", 11.5).attr("font-weight", 700).attr("fill", v < 0 ? "#c0504d" : "#555") .text(fmtVal(v)); } g.append("text").attr("x", xs(lbl)).attr("y", baseY + 16).attr("text-anchor", "middle") .attr("font-size", 11.5).attr("fill", "#888").text(lbl); }); g.append("line").attr("x1", PAD - 6).attr("x2", PW - PAD + 6) .attr("y1", 26 + PH - 46).attr("y2", 26 + PH - 46).attr("class", "domain-line"); }); drawLegend([]); } // ---------- chart image / race video download ---------- const logoDataCache = {}; async function inlineLogos(svgEl) { const clone = svgEl.cloneNode(true); for (const img of clone.querySelectorAll("image")) { const href = img.getAttribute("href"); if (!href || href.startsWith("data:")) continue; if (!(href in logoDataCache)) { try { const blob = await (await fetch(href)).blob(); logoDataCache[href] = await new Promise(res => { const r = new FileReader(); r.onload = () => res(r.result); r.readAsDataURL(blob); }); } catch { logoDataCache[href] = null; } } if (logoDataCache[href]) img.setAttribute("href", logoDataCache[href]); else img.remove(); } return clone; } const EXPORT_CSS = ` text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; } .tick-label-y { font-size: 15px; fill: #333; } .tick-label-x { font-size: 16px; fill: #333; } .axis-title { font-size: 14px; font-weight: 700; fill: #222; } .gridline { stroke: #ececec; stroke-width: 1; } .domain-line { stroke: #d9d9d9; stroke-width: 1.5; } `; async function svgToImage(svgEl) { const clone = await inlineLogos(svgEl); // page CSS doesn't travel with a serialized svg — inline it, and swap the // webfont (unavailable inside ) for a similar heavy system stack const st = document.createElementNS("http://www.w3.org/2000/svg", "style"); st.textContent = EXPORT_CSS; clone.insertBefore(st, clone.firstChild); for (const t of clone.querySelectorAll("text")) { const ff = t.getAttribute("font-family"); if (ff && ff.includes("Poppins")) t.setAttribute("font-family", "Futura, 'Avenir Next', 'Arial Black', 'Segoe UI', sans-serif"); } clone.setAttribute("width", CW); clone.setAttribute("height", clone.viewBox.baseVal.height); const xml = new XMLSerializer().serializeToString(clone); const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml" })); const img = new Image(); await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url; }); URL.revokeObjectURL(url); return img; } function drawFrame(ctx, cw, headH, img) { ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.fillStyle = "#111"; ctx.font = "800 44px Poppins, sans-serif"; ctx.fillText(document.getElementById("charttitle").textContent.toUpperCase(), 40, 62); ctx.fillStyle = "#888"; ctx.font = "22px -apple-system, sans-serif"; ctx.fillText(document.getElementById("chartsub").textContent, 40, 98); ctx.drawImage(img, 0, headH, cw, img.height * (cw / img.width)); } async function downloadImage() { const svgEl = document.querySelector("#chart svg"); if (!svgEl) return; await document.fonts.ready; const img = await svgToImage(svgEl); const CWpx = 2000, headH = 130; const canvas = document.createElement("canvas"); canvas.width = CWpx; canvas.height = headH + img.height * (CWpx / img.width); drawFrame(canvas.getContext("2d"), CWpx, headH, img); const a = document.createElement("a"); a.href = canvas.toDataURL("image/png"); a.download = `hub-oss_${state.view}_${state.metric}_${state.type}.png`; a.click(); } async function recordRaceVideo(btn) { racePause(); await document.fonts.ready; const svgEl = document.querySelector("#chart svg"); if (!svgEl) return; const probe = await svgToImage(svgEl); const CWpx = 1600, headH = 104; const canvas = document.createElement("canvas"); canvas.width = CWpx; canvas.height = Math.round(headH + probe.height * (CWpx / probe.width)); const ctx = canvas.getContext("2d"); const mime = ["video/mp4;codecs=avc1", "video/mp4", "video/webm;codecs=vp9", "video/webm"] .find(m => MediaRecorder.isTypeSupported(m)); const stream = canvas.captureStream(30); const rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 8e6 }); const chunks = []; rec.ondataavailable = e => chunks.push(e.data); const done = new Promise(res => rec.onstop = res); rec.start(); const ents = entitySeries(); const valid = w => ents.some(e => e.V[w] != null); const dur = +document.getElementById("speed").value; btn.textContent = "⏺ recording…"; btn.disabled = true; chartDiv.selectAll("svg").remove(); if (state.view === "line") { lineRaceReset(); const st = lineRaceBuild(null); if (st) { const wkPerSec = 2700 / dur; let lastT = performance.now(); while (st.f < st.hi) { const now = performance.now(); st.f = Math.min(st.hi, st.f + ((now - lastT) / 1000) * wkPerSec); lastT = now; st.update(st.f); const img = await svgToImage(document.querySelector("#chart svg")); drawFrame(ctx, CWpx, headH, img); } } } else for (let w = state.i0; w <= state.i1; w++) { if (!valid(w)) continue; renderFrame(w); await new Promise(r => setTimeout(r, Math.max(dur, 120))); const img = await svgToImage(document.querySelector("#chart svg")); drawFrame(ctx, CWpx, headH, img); } await new Promise(r => setTimeout(r, 600)); rec.stop(); await done; const ext = mime.startsWith("video/mp4") ? "mp4" : "webm"; const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob(chunks, { type: mime })); a.download = `hub-oss_race_${state.view}_${state.metric}_${state.type}.${ext}`; a.click(); URL.revokeObjectURL(a.href); btn.textContent = "⬇ MP4"; btn.disabled = false; } // ---------- shared hover for line ---------- function hoverLayer(svg, g, x, IW, IH, idxs, shown, valAt) { const cross = g.append("line").attr("stroke", "#bbb").attr("stroke-width", 1) .attr("stroke-dasharray", "3 3").attr("y1", 0).attr("y2", IH).style("display", "none"); svg.append("rect").attr("x", M.left).attr("y", M.top).attr("width", IW).attr("height", IH) .attr("fill", "transparent") .on("mousemove", ev => { const [mx] = d3.pointer(ev, g.node()); const target = +x.invert(mx); let best = idxs[0]; for (const w of idxs) if (Math.abs(+DATES[w] - target) < Math.abs(+DATES[best] - target)) best = w; cross.style("display", null).attr("x1", x(DATES[best])).attr("x2", x(DATES[best])); const rows = shown.map(e => ({ e, v: valAt(best)(e) })).sort((a, b) => (b.v ?? -1) - (a.v ?? -1)); tooltip.innerHTML = `
${fmtWeek(DATES[best])}
` + rows.map(r => `
${r.e.name}${fmtVal(r.v)}
`).join(""); tooltip.style.display = "block"; const host = document.getElementById("chart").getBoundingClientRect(); const px = ev.clientX - host.left, py = ev.clientY - host.top; tooltip.style.left = (px + 18 + 180 > host.width ? px - 200 : px + 18) + "px"; tooltip.style.top = Math.max(0, py - 30) + "px"; }) .on("mouseleave", () => { cross.style("display", "none"); tooltip.style.display = "none"; }); } function drawLegend(shown) { const el = d3.select("#legend"); el.selectAll("*").remove(); el.selectAll("span").data(shown).join("span").attr("class", "li") .html(e => `${iconHTML(e)} ${e.name}`); } // ---------- brush slider ---------- function renderBrush() { const BH = 56, IW = CW - M.left - M.right + 60; const div = d3.select("#brushrow"); div.selectAll("*").remove(); const svg = div.append("svg").attr("viewBox", `0 0 ${CW} ${BH}`); const g = svg.append("g").attr("transform", `translate(${M.left},8)`); const x = d3.scaleTime().domain([DATES[0], DATES[W-1]]).range([0, IW]); // context sparkline: total across all orgs of current metric (weekly delta) const ents = entitySeries(); const tot = d3.range(W).map(w => d3.sum(ents, e => e.V[w] ?? 0)); const deltas = d3.range(W).map(w => w === 0 ? 0 : Math.max(0, tot[w] - tot[w-1])); const ymax = d3.max(deltas) || 1; const yy = d3.scaleLinear().domain([0, ymax]).range([28, 0]); g.append("path") .attr("d", d3.area().x((d,i)=>x(DATES[i])).y0(28).y1(d=>yy(d)).curve(d3.curveMonotoneX)(deltas)) .attr("fill", "#eceff1"); g.append("g").attr("transform", "translate(0,28)") .call(d3.axisBottom(x).ticks(8).tickFormat(d3.timeFormat("%b %y")).tickSize(3)) .call(s => { s.select(".domain").attr("stroke", "#d9d9d9"); s.selectAll("text").attr("fill", "#888").attr("font-size", 11); s.selectAll("line").attr("stroke", "#ccc"); }); const brush = d3.brushX().extent([[0, -2], [IW, 30]]) .on("end", ev => { if (!ev.selection || !ev.sourceEvent) return; const [a, b] = ev.selection.map(x.invert); const bi = d => d3.bisectCenter(DATES.map(dd => +dd), +d); const n0 = Math.max(0, Math.min(W - 2, bi(a))), n1 = Math.max(n0 + 1, Math.min(W - 1, bi(b))); state.i0 = n0; state.i1 = n1; raceWeek = null; update(); }); const bg = g.append("g").call(brush); bg.call(brush.move, [x(DATES[state.i0]), x(DATES[state.i1])]); bg.select(".selection").attr("fill", "#111").attr("fill-opacity", 0.06).attr("stroke", "#999"); } // ---------- advanced chips ---------- function renderChips() { const { all } = visibleEntities(); const sorted = [...all].sort((a, b) => b.val - a.val); const el = d3.select("#chips"); el.selectAll("*").remove(); el.selectAll("button").data(sorted).join("button") .attr("class", e => "chip" + (state.pins.has(e.name) ? " pin" : state.hidden.has(e.name) ? " hide" : "")) .html(e => `${iconHTML(e)}${e.name} ${fmtVal(e.val)}`) .on("click", (ev, e) => { if (state.pins.has(e.name)) { state.pins.delete(e.name); state.hidden.add(e.name); } else if (state.hidden.has(e.name)) state.hidden.delete(e.name); else state.pins.add(e.name); update(); }); } // ---------- chart headline (encodes every active setting; screenshot-ready) ---------- let titleDirty = false; document.getElementById("title").addEventListener("input", () => titleDirty = true); function renderTitles() { const fmt = d3.timeFormat("%b %-d, %Y"); const d0 = fmt(DATES[state.i0]), d1 = fmt(DATES[state.i1]); const t = { models: "models", datasets: "datasets", spaces: "Spaces", all: "repos", deriv_eco: "community derivatives", demo_eco: "community demo Spaces", everything: "repos + ecosystem" }[state.type]; let head, sub; { const originT = { all: t, noext: "homegrown " + t, orig: "ground-up original " + t, anyderiv: "fine-tune & quant " + t, ext: t + " built on other orgs' models" }; const tt = (state.type === "models" || state.type === "all") && !document.getElementById("origin").disabled ? originT[state.origin] : t; const met = state.metric === "volume" ? (state.allRepos ? "total " + tt : "new " + tt) : state.metric === "likes" ? (state.allRepos ? `total likes · ${tt}` : `likes gained · ${tt}`) : (state.allRepos ? `total downloads · ${tt}` : `downloads gained · ${tt}`); const orig = ""; const grp = { company: "", country: " · by country", category: " · Big Tech vs Neolabs", origin: " · originals vs fine-tunes" }[state.group]; const cat = { all: "", bigtech: " · Big Tech", neolab: " · Neolabs" }[state.cat]; const ctry = state.country === "all" ? "" : ` · ${state.country}`; head = `${met}${orig}${cat}${ctry}${grp}`; const per = state.view === "periods" ? { year: "per year · ", half: "per half-year · ", quarter: "per quarter · " }[document.getElementById("plen").value] : ""; const wk = state.view === "weekly" ? "weekly additions · " : per; sub = `${wk}${d0} – ${d1} · weekly snapshots of the Hugging Face Hub`; } document.getElementById("charttitle").textContent = head; document.getElementById("chartsub").textContent = sub; document.getElementById("subtitle").textContent = "Models, datasets, Spaces and their ecosystem across AI orgs · data: hfmlsoc/hub_weekly_snapshots"; renderFootnote(); } function renderFootnote() { const notes = []; if (state.view === "demos" || state.type === "demo_eco" || state.type === "everything") notes.push("Demo Spaces — community Spaces that run or reference a tracked org's models (the Hub's “Spaces using this model” links). Values are Space likes / counts; Spaces have no download numbers."); if (state.type === "deriv_eco" || state.type === "everything") notes.push("Community derivatives — models by other authors that declare a tracked org's model as their base: fine-tunes, quantizations, adapters and merges (from model-card metadata; untagged derivatives are missed)."); if (state.origin === "noext") notes.push("Homegrown — an org's models excluding those whose base model belongs to another company (e.g. NVIDIA's quants of Llama don't count), while keeping its variants of its own models."); if (state.origin === "orig" || state.group === "origin") notes.push("Originals — models with no declared base model. Own fine-tunes/quants — based on the same company's models. Others' fine-tunes/quants — based on another company's models."); if (state.origin === "anyderiv") notes.push("Fine-tunes/quants only — models that declare any base model (their own or another org's)."); if (state.metric === "downloads") notes.push("Downloads — cumulative all-time downloads of models/datasets; tracked in the snapshots since late Feb 2025, so series start there."); if (state.group === "category" || state.cat !== "all") notes.push("Big Tech — established tech corporations. Neolabs — AI-native labs, startups and research institutes."); if (!state.allRepos) notes.push("Values are re-based to the selected window start — each series shows NET change inside the window (deletions subtract; the line chart floors at zero when an org deletes more than it adds, e.g. PaddlePaddle removed ~75 Spaces in Apr 2025)."); notes.push("Weekly public-metadata snapshots (2024-07-24 → present); earlier weeks reconstructed from creation dates of repos still alive in mid-2024. Stale or partial snapshot weeks are treated as missing and skipped."); document.getElementById("footnote").innerHTML = notes.map(n => "• " + n).join("
"); } // ---------- CSV export of the current view ---------- function exportCSV() { const esc = s => /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; let rows; { const { shown } = visibleEntities(); rows = [["date","entity","value_in_window","weekly_new","raw_total"]]; for (let w = state.i0; w <= state.i1; w++) for (const e of shown) rows.push([WEEKS[w], e.name, rebasedValue(e, w) ?? "", weeklyDelta(e, w) ?? "", e.V[w] ?? ""]); } const csv = rows.map(r => r.map(c => esc(String(c))).join(",")).join("\n"); const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" })); a.download = `hub-oss_${state.view}_${state.metric}_${state.type}_${WEEKS[state.i0]}_${WEEKS[state.i1]}.csv`; a.click(); URL.revokeObjectURL(a.href); } // ---------- update loop ---------- function update() { racePause(); lineRaceReset(); if (state.type === "demo_eco" && state.metric === "downloads") { state.metric = "likes"; document.getElementById("metric").value = "likes"; } if (ECO_TYPES.has(state.type) && state.group === "origin") { state.group = "company"; document.getElementById("group").value = "company"; } if (state.view === "race") state.view = "bars"; // legacy hash values if (state.view === "demos") state.view = "line"; document.getElementById("racebar").classList.toggle("open", state.view === "bars" || state.view === "line"); document.getElementById("period").style.display = state.view === "bars" ? "" : "none"; document.getElementById("dlmp4").style.display = (state.view === "bars" || state.view === "line") ? "" : "none"; d3.selectAll("#view-seg button").classed("on", function() { return this.dataset.v === state.view; }); d3.selectAll("#mode-seg button").classed("on", function() { return (this.dataset.m === "total") === state.allRepos; }); document.getElementById("origin").disabled = state.group === "origin" || ECO_TYPES.has(state.type) || (state.type !== "models" && state.type !== "all"); renderTitles(); renderChips(); document.getElementById("plen").style.display = state.view === "periods" ? "" : "none"; if (state.view === "line") renderLine(); else if (state.view === "bars") { chartDiv.selectAll("svg").remove(); renderRace(raceWeek ?? state.i1); } else if (state.view === "periods") renderPeriods(); else renderWeekly(); saveHash(); } // ---------- wiring ---------- for (const id of ["metric", "type", "group", "cat", "country", "origin"]) { document.getElementById(id).addEventListener("change", ev => { state[id] = ev.target.value; if (id === "group") { state.pins.clear(); state.hidden.clear(); } update(); renderBrush(); }); } d3.selectAll("#view-seg button").on("click", function() { state.view = this.dataset.v; update(); }); document.getElementById("topn").addEventListener("change", ev => { state.topN = Math.max(1, +ev.target.value || 10); update(); }); d3.selectAll("#mode-seg button").on("click", function() { state.allRepos = this.dataset.m === "total"; update(); }); document.getElementById("adv-toggle").addEventListener("click", () => { document.getElementById("advanced").classList.toggle("open"); document.getElementById("adv-toggle").classList.toggle("on"); }); document.getElementById("play").addEventListener("click", () => raceTimer ? racePause() : racePlay()); document.getElementById("dlcsv").addEventListener("click", exportCSV); document.getElementById("dlimg").addEventListener("click", () => downloadImage()); document.getElementById("dlmp4").addEventListener("click", () => recordRaceVideo(document.getElementById("dlmp4"))); document.getElementById("period").addEventListener("change", () => { if (state.view === "race") { racePause(); renderRace(raceWeek); } }); document.getElementById("plen").addEventListener("change", update); document.getElementById("racescrub").addEventListener("input", ev => { racePause(); raceWeek = +ev.target.value; renderFrame(raceWeek); }); document.getElementById("restart").addEventListener("click", () => { racePause(); raceWeek = state.i0; renderFrame(raceWeek); }); // country options const countries = [...new Set(UI_DATA.orgs.map(o => o.n))].sort(); d3.select("#country").selectAll("option.c").data(countries).join("option").attr("class", "c") .attr("value", d => d).text(d => d); // hash state (shareable / screenshot presets) function saveHash() { const h = new URLSearchParams(); for (const k of ["view","metric","type","group","cat","country","origin"]) if (state[k] !== undefined) h.set(k, state[k]); h.set("n", state.topN); h.set("r", state.i0 + "-" + state.i1); if (state.pins.size) h.set("pin", [...state.pins].join("|")); if (state.hidden.size) h.set("hide", [...state.hidden].join("|")); if (state.allRepos) h.set("stock", 1); history.replaceState(null, "", "#" + h.toString()); } function loadHash() { if (!location.hash.length) return; const h = new URLSearchParams(location.hash.slice(1)); for (const k of ["view","metric","type","group","cat","country","origin"]) if (h.get(k)) state[k] = h.get(k); if (h.get("n")) state.topN = +h.get("n"); if (h.get("r")) { const [a, b] = h.get("r").split("-").map(Number); state.i0 = a || 0; state.i1 = b || W - 1; } if (h.get("pin")) state.pins = new Set(h.get("pin").split("|")); if (h.get("hide")) state.hidden = new Set(h.get("hide").split("|")); state.allRepos = h.get("stock") === "1"; for (const k of ["metric","type","group","cat","country","origin"]) { const el = document.getElementById(k); if (el) el.value = state[k]; } document.getElementById("topn").value = state.topN; } loadHash(); update(); renderBrush();