// Pure (fs-free, React-free) presentation helpers + control types shared by the // hub landing's cross-site chart (common/components/charts/CrossSiteChart) and // its site cards (homepage SiteGrid). Color is keyed on a series' index so the // chart and a card's legend swatch always agree; a SITE's index into this // palette is chosen by lib/siteColor.ts siteChartColors (its accent's hue // family). import type { MetricKey } from "./homepageSummary"; // Chart control axes (each a small segmented button group on the landing). export type Breakdown = "site" | "channel"; export type Bucket = "day" | "week" | "month" | "cumulative"; export type Range = "2w" | "6w" | "90d" | "12mo" | "all"; export type Metric = MetricKey; // "transcribed" | "downloaded" export type Display = "share" | "counts"; // Stable series color. The first six are the validated palette in // common/styles/tokens.css (--chart-1..6, fixed per base); beyond that we // spread hues by the golden angle so many channels / >6 sites stay distinct // (and stable across renders). The sixth was the golden angle's hue 328 until // release 11 — within a degree of --chart-5's magenta. export const CHART_SLOTS = 6; export function seriesColor(index: number): string { if (index < CHART_SLOTS) return `var(--chart-${index + 1})`; const hue = Math.round((index * 137.508) % 360); return `hsl(${hue} 64% 55%)`; } // Muted band for the grouped "Other" channels bucket. export const OTHER_COLOR = "var(--muted-foreground)"; const MONTHS = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; // "2026-06" -> "Jun 2026" export function monthLabel(ym: string): string { const [y, m] = ym.split("-").map(Number); return `${MONTHS[(m ?? 1) - 1] ?? ""} ${y}`; } // "2026-06-22" (week start) -> "Jun 22" export function weekLabel(iso: string): string { const [, m, d] = iso.split("-").map(Number); return `${MONTHS[(m ?? 1) - 1] ?? ""} ${d ?? ""}`; } // "YYYYMMDD" -> "Jun 28, 2026" export function dayLabel(yyyymmdd: string): string { if (yyyymmdd.length < 8) return yyyymmdd; const y = yyyymmdd.slice(0, 4); const m = Number(yyyymmdd.slice(4, 6)); const d = Number(yyyymmdd.slice(6, 8)); return `${MONTHS[m - 1] ?? ""} ${d}, ${y}`; } // --------------------------------------------------------------------------- // Chart redesign: presets + a power-user control matrix. // // The landing leads with a few named *presets* (each a full, valid control // state tuned to the real data), with an inline "Customize" panel exposing the // raw controls. Because one site (Jeralyzer) dwarfs the others, the model adds // an explicit chart type, a linear⇄symlog scale, an indexed/share value mode, // and an exclude-outlier toggle — the tools that keep the small sites legible. // --------------------------------------------------------------------------- export type ChartType = "line" | "area" | "bar" | "barH" | "small"; export type Scale = "linear" | "symlog"; // How values are expressed: raw counts, 100%-stacked share, or indexed to 100 // at each series' window start (% growth). Supersedes the old `Display`. export type ValueMode = "counts" | "share" | "indexed"; export type PresetId = "recent" | "cumulative" | "alltime"; // The seven control fields a preset pins. (hidden/soloId/excludeTop are // per-view filters, not part of a preset's identity.) export type PresetState = { metric: Metric; breakdown: Breakdown; chartType: ChartType; bucket: Bucket; range: Range; scale: Scale; valueMode: ValueMode; }; export type ChartState = PresetState & { preset: PresetId | "custom"; hidden: ReadonlySet; soloId: string | null; // isolate one series (overrides hidden) excludeTop: boolean; // drop the largest site and rescale (site breakdown only) }; // Presets are deliberately simple: always per-site, always one combined line // chart. The recent views use a ~6-week window where the three sites are // comparable (the May megaspike sits outside it), so plain linear lines read // clearly. All-time is the one tamed historical view (symlog so the dominant // site doesn't slam it). The full toolbox (channel / small multiples / ranked / // share / indexed) still lives in Customize. export const PRESETS: Record< PresetId, { label: string; hint: string; state: PresetState } > = { recent: { label: "Recent", hint: "The last six weeks, week by week", state: { metric: "transcribed", breakdown: "site", chartType: "line", bucket: "week", range: "6w", scale: "linear", valueMode: "counts", }, }, cumulative: { label: "Cumulative", hint: "Running totals over the last six weeks", state: { metric: "transcribed", breakdown: "site", chartType: "line", bucket: "cumulative", range: "6w", scale: "linear", valueMode: "counts", }, }, alltime: { label: "All-time", hint: "Every week since launch, on a symlog axis", state: { metric: "transcribed", breakdown: "site", chartType: "line", bucket: "week", range: "all", scale: "symlog", valueMode: "counts", }, }, }; export const PRESET_ORDER: PresetId[] = ["recent", "cumulative", "alltime"]; export const DEFAULT_STATE: ChartState = { ...PRESETS.recent.state, preset: "recent", hidden: new Set(), soloId: null, excludeTop: false, }; export function isStacked(t: ChartType): boolean { return t === "area" || t === "bar"; } // Coerce a state to a self-consistent one. chartType is authoritative: scale // and valueMode yield to it (never the reverse), so there are no cascades. // - share needs a stacked chart; indexed needs a line. // - symlog needs a non-stacked chart (it composes with indexed, which is a // line — symlog keeps the growth multiples comparable across series). // - excludeTop only applies to the site breakdown. export function normalize(s: ChartState): ChartState { const stacked = isStacked(s.chartType); let valueMode = s.valueMode; if (valueMode === "share" && !stacked) valueMode = "counts"; if (valueMode === "indexed" && s.chartType !== "line") valueMode = "counts"; let scale = s.scale; if (scale === "symlog" && stacked) scale = "linear"; const excludeTop = s.breakdown === "site" ? s.excludeTop : false; return { ...s, valueMode, scale, excludeTop }; } // Whether a given option in the Customize matrix is currently invalid (greyed), // so the UI can explain *why* a combination isn't available. export function isDisabled( key: keyof PresetState | "excludeTop", value: string, s: ChartState, ): boolean { const stacked = isStacked(s.chartType); if (key === "valueMode" && value === "share") return !stacked; if (key === "valueMode" && value === "indexed") return s.chartType !== "line"; if (key === "scale" && value === "symlog") return stacked; if (key === "excludeTop") return s.breakdown !== "site"; return false; } const CORE_KEYS: (keyof PresetState)[] = [ "metric", "breakdown", "chartType", "bucket", "range", "scale", "valueMode", ]; // Which preset (if any) a state currently matches — drives the active chip. export function presetOf(s: ChartState): PresetId | "custom" { for (const id of PRESET_ORDER) { const p = PRESETS[id].state; if (CORE_KEYS.every((k) => s[k] === p[k])) return id; } return "custom"; } export function applyPreset(prev: ChartState, id: PresetId): ChartState { return { ...prev, ...PRESETS[id].state, preset: id, hidden: new Set(), soloId: null, excludeTop: false, }; } // Apply a control change: merge, clear stale solo on a breakdown switch, // normalize, then recompute which preset (or "custom") we landed on. export function applyPatch( prev: ChartState, patch: Partial, ): ChartState { let next: ChartState = { ...prev, ...patch }; if ("breakdown" in patch) next.soloId = null; next = normalize(next); next.preset = presetOf(next); return next; } // --- symlog axis helpers (shared by the line + leaderboard charts) --- // Half-width of symlog's near-linear region around 0. Tuned for a ~1–100/day // floor against a ~16k spike; a visual knob, not load-bearing. export const SYMLOG_CONSTANT = 10; const compactFmt = new Intl.NumberFormat("en", { notation: "compact" }); export function compactNumber(n: number): string { return compactFmt.format(n); } // Powers of ten within [10, max], plus 0 — the symlog tick set (0 is why we use // symlog over log: the data is full of zero buckets). export function symlogTicks(max: number): number[] { const ticks = [0]; for (let t = 10; t <= max; t *= 10) ticks.push(t); return ticks; }