// Pure (React-free) data pipeline shared by every cross-site chart. Turns the // build-time summary's BucketSeries arrays into the rows each chart type needs — // applying the window, cumulative/indexed transforms, exclude-top / solo / hide // filters, and channel "Other" grouping — so the chart components stay thin and // the skew-handling logic lives in one tested place. import type { BucketSeries, HomepageSummary } from "./homepageSummary"; import { seriesColor, OTHER_COLOR, weekLabel, monthLabel, type ChartState, } from "./homepageChart"; import { siteChartColors } from "./siteColor"; type Gran = "day" | "week" | "month"; // Trailing buckets shown per range, by granularity (day/cumulative bin on the // day series; week/month on their own). const WINDOW: Record> = { "2w": { day: 14, week: 2, month: 1 }, "6w": { day: 42, week: 6, month: 2 }, "90d": { day: 90, week: 13, month: 3 }, "12mo": { day: 365, week: 52, month: 12 }, all: { day: null, week: null, month: null }, }; // Channels beyond this fold into one "Other" band on time-series charts. const TOP_CHANNELS_SERIES = 8; export type ChartSeries = { id: string; label: string; color: string; // null = before a series' indexed baseline (so the line starts cleanly at 100) values: (number | null)[]; }; export type RankRow = { id: string; label: string; color: string; value: number; }; function runningSum(v: number[]): number[] { let acc = 0; return v.map((x) => (acc += x)); } function granOf(bucket: ChartState["bucket"]): Gran { return bucket === "week" ? "week" : bucket === "month" ? "month" : "day"; } function pad(vals: number[] | undefined, len: number): number[] { return Array.from({ length: len }, (_, i) => vals?.[i] ?? 0); } function windowTotal( vals: number[] | undefined, len: number, start: number, ): number { const p = pad(vals, len); let s = 0; for (let i = start; i < len; i++) s += p[i]; return s; } function sourceAndWindow(summary: HomepageSummary, state: ChartState) { const gran = granOf(state.bucket); const src: BucketSeries = summary.series[state.metric][gran]; const len = src.buckets.length; const win = WINDOW[state.range][gran]; const start = win == null ? 0 : Math.max(0, len - win); return { gran, src, len, start }; } type Entry = { id: string; label: string; color: string; full: number[] }; // One entry per site (in summary order) or per channel (sorted by window total). // A site wears its chart colour (siteChartColors: its accent's hue family in // the validated palette, else seriesColor at its index) — the colour its // SiteGrid card and the homepage's growth chart give it. function entries( summary: HomepageSummary, state: ChartState, src: BucketSeries, len: number, start: number, ): Entry[] { if (state.breakdown === "site") { const colors = siteChartColors(summary.sites); return summary.sites.map((s, i) => ({ id: s.siteId, label: s.siteTitle, color: colors[i], full: pad(src.bySite[s.siteId], len), })); } const nameOf = new Map(summary.channels.map((c) => [c.slug, c.name])); const slugs = Object.keys(src.byChannel).sort( (a, b) => windowTotal(src.byChannel[b], len, start) - windowTotal(src.byChannel[a], len, start) || a.localeCompare(b), ); return slugs.map((slug, i) => ({ id: slug, label: nameOf.get(slug) ?? slug, color: seriesColor(i), full: pad(src.byChannel[slug], len), })); } // Time-series rows for line / area / bar / small-multiples charts. export function buildTimeSeries( summary: HomepageSummary, state: ChartState, ): { labels: string[]; series: ChartSeries[]; max: number } { const { gran, src, len, start } = sourceAndWindow(summary, state); const cumulative = state.bucket === "cumulative"; const indexed = state.valueMode === "indexed"; const labelOf = gran === "month" ? monthLabel : weekLabel; const labels = src.buckets.slice(start).map(labelOf); let list = entries(summary, state, src, len, start); // Drop the largest site (by window total) and let the rest rescale. if (state.excludeTop && list.length > 1) { let topIdx = 0; let topVal = -1; list.forEach((e, i) => { const t = windowTotal(e.full, len, start); if (t > topVal) { topVal = t; topIdx = i; } }); list = list.filter((_, i) => i !== topIdx); } // Channel breakdown: fold the long tail into one "Other" band. if (state.breakdown === "channel" && list.length > TOP_CHANNELS_SERIES) { const top = list.slice(0, TOP_CHANNELS_SERIES); const rest = list.slice(TOP_CHANNELS_SERIES); const otherFull = new Array(len).fill(0); for (const e of rest) for (let i = 0; i < len; i++) otherFull[i] += e.full[i]; top.push({ id: "__other", label: `Other (${rest.length})`, color: OTHER_COLOR, full: otherFull, }); list = top; } // Solo overrides hide; otherwise apply the hidden set. if (state.soloId) list = list.filter((e) => e.id === state.soloId); else list = list.filter((e) => !state.hidden.has(e.id)); const transform = (full: number[]): (number | null)[] => { // Indexed rebases the absolute running total to 100 at the window start. if (indexed) { const cum = runningSum(full).slice(start); const base = cum.find((v) => v > 0) ?? 0; return cum.map((v) => (base <= 0 || v < base ? null : (v / base) * 100)); } // Cumulative is window-RELATIVE: accumulate only within the window (starts // at 0), so a recent slice isn't dominated by a site's historical total. // (For range "all" the window is the whole series, so it's the true total.) if (cumulative) return runningSum(full.slice(start)); return full.slice(start); }; const series: ChartSeries[] = list.map((e) => ({ id: e.id, label: e.label, color: e.color, values: transform(e.full), })); let max = 0; for (const s of series) for (const v of s.values) if (v != null && v > max) max = v; return { labels, series, max }; } // Aggregate ranking (window totals) for the horizontal leaderboard, the long // tail truncated. Channels are re-keyed by rank so the leader is chart-1; a // SITE keeps its own colour (release 10), the one its card and every other // chart give it — colour follows the entity, and the cards are the legend. export function buildRanking( summary: HomepageSummary, state: ChartState, topN: number, ): { rows: RankRow[]; max: number } { const { src, len, start } = sourceAndWindow(summary, state); const list = entries(summary, state, src, len, start) .map((e) => ({ id: e.id, label: e.label, color: e.color, value: windowTotal(e.full, len, start), })) .sort((a, b) => b.value - a.value || a.label.localeCompare(b.label)); const bySite = state.breakdown === "site"; const rows: RankRow[] = list .slice(0, topN) .map((r, i) => ({ ...r, color: bySite ? r.color : seriesColor(i) })); const max = rows.reduce((m, r) => Math.max(m, r.value), 0); return { rows, max }; } // The current top site by window total — used to label the exclude-outlier // toggle ("Exclude Jeralyzer"). export function topSite( summary: HomepageSummary, state: ChartState, ): { id: string; label: string } | null { if (state.breakdown !== "site") return null; const { src, len, start } = sourceAndWindow(summary, state); let top: { id: string; label: string } | null = null; let topVal = -1; for (const s of summary.sites) { const t = windowTotal(src.bySite[s.siteId], len, start); if (t > topVal) { topVal = t; top = { id: s.siteId, label: s.siteTitle }; } } return top; }