Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit a8c21966adf25ca0fcbea8a47f76d3d85d2f7183
parent 47700598931cb926e5027b5ca10cfa916d0ecfb1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 29 Jun 2026 00:48:54 -0400

Rebuild hub charts: presets + symlog explorer for the skewed data

The single five-control chart didn't fit the real data: 3 public sites with
one (Jeralyzer) at 96% of transcripts, 38 channels with a long tail, bursty
activity, and a default (Day · 2-week) that landed on the emptiest slice.

Lead with four data-tuned preset chips + an inline Customize matrix:
- Momentum (default): per-site lines over 90d on a d3 scaleSymlog y-axis, so
  the small sites stay visible instead of flat-lining (symlog, not log, for
  the zero-days).
- Growth: cumulative stacked area over all time.
- Leaderboard: top-12 channels as ranked horizontal bars (symlog value axis).
- Compare: small multiples, one panel per site at its own scale.

Plus the skew tools the data needs: linear⇄symlog toggle, indexed-to-baseline
(% growth, composes with symlog), and exclude-top-site. chart type is the
authoritative control; Symlog/Share/Indexed grey out when invalid.

A shared framework-free pipeline (homepageChartData.ts) feeds every chart; the
plot moves onto an opaque "instrument panel" surface for data contrast. Added
d3-scale to common. No summary-schema change — all derived client-side.

Verified: typecheck (common + homepage), static export build, and a new
homepage Playwright suite (e2e/landing.spec.ts, 6/6) covering each preset, the
symlog small-site visibility, the guard rails, exclude-outlier, indexed
baseline, and mobile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/components/charts/ChartMessage.tsx | 10++++++++++
Acommon/components/charts/ChartPanel.tsx | 39+++++++++++++++++++++++++++++++++++++++
Mcommon/components/charts/CrossSiteChart.tsx | 165++++++++++++++++++-------------------------------------------------------------
Acommon/components/charts/LeaderboardChart.tsx | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/charts/MomentumChart.tsx | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/charts/SmallMultiples.tsx | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/homepageChart.ts | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/homepageChartData.ts | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/package.json | 2++
Mhomepage/CHANGELOG.md | 33+++++++++++++++++++++++++++++++++
Ahomepage/app/components/CustomizePanel.tsx | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/app/components/HomeLanding.tsx | 171++++++++++++++++++++++---------------------------------------------------------
Ahomepage/app/components/PresetBar.tsx | 48++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/components/Segmented.tsx | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/app/globals.css | 17+++++++++++++++++
Ahomepage/e2e/landing.spec.ts | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/playwright.config.ts | 34++++++++++++++++++++++++++++++++++
Mpnpm-lock.yaml | 6++++++
18 files changed, 1303 insertions(+), 252 deletions(-)

diff --git a/common/components/charts/ChartMessage.tsx b/common/components/charts/ChartMessage.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +// Shared empty/placeholder panel so every chart's "no data" state matches. +export function ChartMessage({ children }: { children: ReactNode }) { + return ( + <div className="flex h-[320px] items-center justify-center rounded-xl border border-[var(--border)] px-6 text-center text-sm text-[var(--muted-foreground)]"> + {children} + </div> + ); +} diff --git a/common/components/charts/ChartPanel.tsx b/common/components/charts/ChartPanel.tsx @@ -0,0 +1,39 @@ +"use client"; + +import type { HomepageSummary } from "../../lib/homepageSummary"; +import type { ChartState } from "../../lib/homepageChart"; +import { MomentumChart } from "./MomentumChart"; +import { SmallMultiples } from "./SmallMultiples"; +import { LeaderboardChart } from "./LeaderboardChart"; +import { CrossSiteChart } from "./CrossSiteChart"; + +// Routes a ChartState to the right renderer. Keeps HomeLanding about controls +// and presets; each chart owns its own axis/scale concerns. +export function ChartPanel({ + summary, + state, + onSelectSeries, +}: { + summary: HomepageSummary; + state: ChartState; + onSelectSeries?: (id: string) => void; +}) { + switch (state.chartType) { + case "line": + return ( + <MomentumChart + summary={summary} + state={state} + onSelectSeries={onSelectSeries} + /> + ); + case "small": + return <SmallMultiples summary={summary} state={state} />; + case "barH": + return <LeaderboardChart summary={summary} state={state} />; + case "area": + case "bar": + default: + return <CrossSiteChart summary={summary} state={state} />; + } +} diff --git a/common/components/charts/CrossSiteChart.tsx b/common/components/charts/CrossSiteChart.tsx @@ -18,122 +18,26 @@ import { ChartTooltipContent, type ChartConfig, } from "../ui/chart"; -import type { BucketSeries, HomepageSummary } from "../../lib/homepageSummary"; -import { - seriesColor, - OTHER_COLOR, - weekLabel, - monthLabel, - type Breakdown, - type Bucket, - type Range, - type Metric, - type Display, -} from "../../lib/homepageChart"; - -const TOP_CHANNELS = 8; - -type Gran = "day" | "week" | "month"; - -// How many trailing buckets each range shows, per granularity. -const WINDOW: Record<Range, Record<Gran, number | null>> = { - "2w": { day: 14, week: 2, month: 1 }, - "90d": { day: 90, week: 13, month: 3 }, - "12mo": { day: 365, week: 52, month: 12 }, - all: { day: null, week: null, month: null }, -}; - -function runningSum(v: number[]): number[] { - let acc = 0; - return v.map((x) => (acc += x)); -} - -type Series = { id: string; label: string; color: string; values: number[] }; +import { ChartMessage } from "./ChartMessage"; +import type { HomepageSummary } from "../../lib/homepageSummary"; +import { type ChartState } from "../../lib/homepageChart"; +import { buildTimeSeries } from "../../lib/homepageChartData"; +// The stacked renderer: area (cumulative/Growth) or bars, optionally 100%-share. +// Stacking shows per-series composition *and* the combined total at once — the +// honest view when the series add up to a meaningful whole. Skew-handling for +// non-stacked views lives in the line / small-multiples / leaderboard charts. export function CrossSiteChart({ summary, - breakdown, - bucket, - range, - metric, - display, - hidden, + state, }: { summary: HomepageSummary; - breakdown: Breakdown; - bucket: Bucket; - range: Range; - metric: Metric; - display: Display; - hidden: ReadonlySet<string>; + state: ChartState; }) { - const { rows, series, labels } = useMemo(() => { - const ms = summary.series[metric]; - const cumulative = bucket === "cumulative"; - // Day + cumulative bin on the day series; week/month on their own. - const gran: Gran = bucket === "week" ? "week" : bucket === "month" ? "month" : "day"; - const src: BucketSeries = ms[gran]; - const labelOf = gran === "month" ? monthLabel : weekLabel; - - const win = WINDOW[range][gran]; - const start = win == null ? 0 : Math.max(0, src.buckets.length - win); - const len = src.buckets.length; - const pad = (vals?: number[]) => - Array.from({ length: len }, (_, i) => vals?.[i] ?? 0); - const transform = (vals?: number[]) => { - const full = pad(vals); - return (cumulative ? runningSum(full) : full).slice(start); - }; - - let series: Series[]; - if (breakdown === "site") { - series = summary.sites - .map((s, i) => ({ s, color: seriesColor(i) })) - .filter(({ s }) => !hidden.has(s.siteId)) - .map(({ s, color }) => ({ - id: s.siteId, - label: s.siteTitle, - color, - values: transform(src.bySite[s.siteId]), - })); - } else { - const nameOf = new Map(summary.channels.map((c) => [c.slug, c.name])); - const totalOf = (slug: string) => - (src.byChannel[slug] ?? []).reduce((a, b) => a + b, 0); - const sorted = Object.keys(src.byChannel).sort( - (a, b) => totalOf(b) - totalOf(a) || a.localeCompare(b), - ); - const top = sorted.slice(0, TOP_CHANNELS); - const rest = sorted.slice(TOP_CHANNELS); - series = top.map((slug, i) => ({ - id: slug, - label: nameOf.get(slug) ?? slug, - color: seriesColor(i), - values: transform(src.byChannel[slug]), - })); - if (rest.length) { - const otherFull = pad(); - for (const slug of rest) { - const p = pad(src.byChannel[slug]); - for (let i = 0; i < p.length; i++) otherFull[i] += p[i]; - } - series.push({ - id: "__other", - label: `Other (${rest.length})`, - color: OTHER_COLOR, - values: (cumulative ? runningSum(otherFull) : otherFull).slice(start), - }); - } - } - - const labels = src.buckets.slice(start).map(labelOf); - const rows = labels.map((x, i) => { - const row: Record<string, string | number> = { x }; - for (const s of series) row[s.id] = s.values[i] ?? 0; - return row; - }); - return { rows, series, labels }; - }, [summary, breakdown, bucket, range, metric, hidden]); + const { labels, series } = useMemo( + () => buildTimeSeries(summary, state), + [summary, state], + ); const config = useMemo<ChartConfig>(() => { const c: ChartConfig = {}; @@ -141,29 +45,35 @@ export function CrossSiteChart({ return c; }, [series]); + const rows = useMemo( + () => + labels.map((x, i) => { + const row: Record<string, string | number> = { x }; + for (const s of series) row[s.id] = (s.values[i] as number) ?? 0; + return row; + }), + [labels, series], + ); + if (labels.length === 0) { - return ( - <div className="flex h-[320px] items-center justify-center rounded-xl border border-zinc-200 dark:border-zinc-800 text-sm text-zinc-500"> - No data for this metric yet. - </div> - ); + return <ChartMessage>No data for this view yet.</ChartMessage>; } if (series.length === 0) { return ( - <div className="flex h-[320px] items-center justify-center rounded-xl border border-zinc-200 dark:border-zinc-800 text-sm text-zinc-500"> - All sites hidden — re-enable one from the cards below. - </div> + <ChartMessage> + Every series is hidden — re-enable one from the cards below. + </ChartMessage> ); } - const isArea = bucket === "cumulative"; - const isShare = display === "share"; + const isArea = state.chartType === "area"; + const isShare = state.valueMode === "share"; const stackOffset = isShare ? "expand" : undefined; - const showLegend = breakdown === "channel" && series.length > 1; + const showLegend = state.breakdown === "channel" && series.length > 1; const margin = { top: 8, right: 12, bottom: 4, left: 4 }; const axes = [ - <CartesianGrid key="grid" vertical={false} strokeDasharray="3 3" />, + <CartesianGrid key="grid" vertical={false} stroke="var(--chart-grid)" strokeDasharray="3 3" />, <XAxis key="x" dataKey="x" @@ -171,20 +81,19 @@ export function CrossSiteChart({ axisLine={false} tickMargin={8} minTickGap={24} + tick={{ fill: "var(--chart-axis)", fontSize: 12 }} />, <YAxis key="y" tickLine={false} axisLine={false} width={isShare ? 40 : 44} - // Share = 100% stacked: pin the domain so the stack fills the height (and - // give it fractional ticks). Counts: integer auto-scale. + // Share = 100% stacked: pin [0,1] so the stack fills the height. domain={isShare ? [0, 1] : undefined} ticks={isShare ? [0, 0.25, 0.5, 0.75, 1] : undefined} - allowDecimals={isShare ? true : false} - tickFormatter={ - isShare ? (v: number) => `${Math.round(v * 100)}%` : undefined - } + allowDecimals={isShare} + tickFormatter={isShare ? (v: number) => `${Math.round(v * 100)}%` : undefined} + tick={{ fill: "var(--chart-axis)", fontSize: 12 }} />, <ChartTooltip key="tip" content={<ChartTooltipContent />} />, showLegend ? ( diff --git a/common/components/charts/LeaderboardChart.tsx b/common/components/charts/LeaderboardChart.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useMemo } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + LabelList, + XAxis, + YAxis, +} from "recharts"; +import { scaleSymlog } from "d3-scale"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "../ui/chart"; +import { ChartMessage } from "./ChartMessage"; +import type { HomepageSummary } from "../../lib/homepageSummary"; +import { + SYMLOG_CONSTANT, + compactNumber, + symlogTicks, + type ChartState, +} from "../../lib/homepageChart"; +import { buildRanking } from "../../lib/homepageChartData"; + +const TOP_N = 12; + +// Ranked horizontal bars — an aggregate over the window, not a time series. With +// one bar at ~27k and a floor in the hundreds, a symlog value axis keeps the +// small channels readable next to the leader (linear via the scale toggle). +export function LeaderboardChart({ + summary, + state, +}: { + summary: HomepageSummary; + state: ChartState; +}) { + const { rows, max } = useMemo( + () => buildRanking(summary, state, TOP_N), + [summary, state], + ); + const symlog = useMemo(() => scaleSymlog().constant(SYMLOG_CONSTANT), []); + + if (rows.length === 0) { + return <ChartMessage>No data for this view yet.</ChartMessage>; + } + + const config: ChartConfig = { value: { label: "Transcripts" } }; + const isSymlog = state.scale === "symlog"; + const domainMax = Math.max(10, max); + const ticks = isSymlog ? symlogTicks(domainMax) : undefined; + + return ( + <ChartContainer config={config} className="aspect-auto h-[420px] w-full"> + <BarChart + data={rows} + layout="vertical" + margin={{ top: 4, right: 52, bottom: 4, left: 8 }} + accessibilityLayer + > + <CartesianGrid horizontal={false} stroke="var(--chart-grid)" strokeDasharray="3 3" /> + <XAxis + type="number" + scale={isSymlog ? (symlog as unknown as "linear") : undefined} + domain={[0, domainMax]} + ticks={ticks} + tickFormatter={(v: number) => compactNumber(v)} + tickLine={false} + axisLine={false} + tick={{ fill: "var(--chart-axis)", fontSize: 12 }} + /> + <YAxis + type="category" + dataKey="label" + width={132} + tickLine={false} + axisLine={false} + tick={{ fill: "var(--chart-axis)", fontSize: 12 }} + /> + <ChartTooltip content={<ChartTooltipContent />} /> + <Bar dataKey="value" radius={[0, 3, 3, 0]} isAnimationActive={false}> + {rows.map((r) => ( + <Cell key={r.id} fill={r.color} /> + ))} + <LabelList + dataKey="value" + position="right" + formatter={(v: number) => compactNumber(v)} + className="fill-[var(--muted-foreground)] text-xs tabular-nums" + /> + </Bar> + </BarChart> + </ChartContainer> + ); +} diff --git a/common/components/charts/MomentumChart.tsx b/common/components/charts/MomentumChart.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useMemo } from "react"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { scaleSymlog } from "d3-scale"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "../ui/chart"; +import { ChartMessage } from "./ChartMessage"; +import type { HomepageSummary } from "../../lib/homepageSummary"; +import { + SYMLOG_CONSTANT, + compactNumber, + symlogTicks, + type ChartState, +} from "../../lib/homepageChart"; +import { buildTimeSeries } from "../../lib/homepageChartData"; + +// One line per series over a trailing window — the only chart that can carry a +// symlog y-axis (lines, not stacked, so a non-additive axis is meaningful). With +// one site dwarfing the rest, symlog lifts the small sites off the floor; symlog +// (not log) because the daily series is full of zeros. Also renders the indexed +// (% growth) value mode, where every series starts at 100. +export function MomentumChart({ + summary, + state, + onSelectSeries, +}: { + summary: HomepageSummary; + state: ChartState; + onSelectSeries?: (id: string) => void; +}) { + const { labels, series, max } = useMemo( + () => buildTimeSeries(summary, state), + [summary, state], + ); + + const config = useMemo<ChartConfig>(() => { + const c: ChartConfig = {}; + for (const s of series) c[s.id] = { label: s.label, color: s.color }; + return c; + }, [series]); + + const rows = useMemo( + () => + labels.map((x, i) => { + const row: Record<string, string | number | null> = { x }; + for (const s of series) row[s.id] = s.values[i] ?? null; + return row; + }), + [labels, series], + ); + + // recharts mutates the scale's domain/range; the constant is what we own. + const symlog = useMemo(() => scaleSymlog().constant(SYMLOG_CONSTANT), []); + + if (series.length === 0 || rows.length === 0) { + return ( + <ChartMessage> + {state.soloId || state.hidden.size > 0 + ? "Every series is hidden — re-enable one from the cards below." + : "No data for this view yet."} + </ChartMessage> + ); + } + + const isSymlog = state.scale === "symlog"; + const domainMax = Math.max(10, max); + const ticks = isSymlog ? symlogTicks(domainMax) : undefined; + + return ( + <ChartContainer + config={config} + className="aspect-auto h-[320px] w-full sm:h-[380px]" + > + <LineChart data={rows} margin={{ top: 8, right: 12, bottom: 4, left: 4 }} accessibilityLayer> + <CartesianGrid vertical={false} stroke="var(--chart-grid)" strokeDasharray="3 3" /> + <XAxis + dataKey="x" + tickLine={false} + axisLine={false} + tickMargin={8} + minTickGap={28} + tick={{ fill: "var(--chart-axis)", fontSize: 12 }} + /> + <YAxis + type="number" + // d3 symlog instance ⇒ skew-taming axis; otherwise linear auto-scale. + scale={isSymlog ? (symlog as unknown as "linear") : undefined} + domain={isSymlog ? [0, domainMax] : undefined} + ticks={ticks} + tickFormatter={(v: number) => compactNumber(v)} + tickLine={false} + axisLine={false} + width={44} + allowDecimals={false} + tick={{ fill: "var(--chart-axis)", fontSize: 12 }} + /> + <ChartTooltip content={<ChartTooltipContent />} /> + {series.map((s) => ( + <Line + key={s.id} + dataKey={s.id} + name={s.label} + type="monotone" + stroke={s.color} + strokeWidth={1.75} + dot={false} + connectNulls + isAnimationActive={false} + onClick={onSelectSeries ? () => onSelectSeries(s.id) : undefined} + style={onSelectSeries ? { cursor: "pointer" } : undefined} + /> + ))} + </LineChart> + </ChartContainer> + ); +} diff --git a/common/components/charts/SmallMultiples.tsx b/common/components/charts/SmallMultiples.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useMemo } from "react"; +import { Area, AreaChart, YAxis } from "recharts"; +import { scaleSymlog } from "d3-scale"; +import { ChartContainer, type ChartConfig } from "../ui/chart"; +import { ChartMessage } from "./ChartMessage"; +import type { HomepageSummary } from "../../lib/homepageSummary"; +import { + SYMLOG_CONSTANT, + compactNumber, + symlogTicks, + type ChartState, +} from "../../lib/homepageChart"; +import { buildTimeSeries, type ChartSeries } from "../../lib/homepageChartData"; + +// One mini-panel per series, each on its OWN y-scale — so the outlier can't +// crush the others by construction (the honest per-shape comparison). With three +// sites this is a clean 3-up; the panel scale follows the chart's linear/symlog. +export function SmallMultiples({ + summary, + state, +}: { + summary: HomepageSummary; + state: ChartState; +}) { + const { labels, series } = useMemo( + () => buildTimeSeries(summary, state), + [summary, state], + ); + + if (series.length === 0) { + return <ChartMessage>No data for this view yet.</ChartMessage>; + } + + return ( + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> + {series.map((s) => ( + <Panel key={s.id} s={s} labels={labels} scale={state.scale} /> + ))} + </div> + ); +} + +function Panel({ + s, + labels, + scale, +}: { + s: ChartSeries; + labels: string[]; + scale: ChartState["scale"]; +}) { + const config = useMemo<ChartConfig>( + () => ({ v: { label: s.label, color: s.color } }), + [s.label, s.color], + ); + const rows = useMemo( + () => labels.map((x, i) => ({ x, v: s.values[i] ?? null })), + [labels, s.values], + ); + const symlog = useMemo(() => scaleSymlog().constant(SYMLOG_CONSTANT), []); + + const max = s.values.reduce<number>((m, v) => (v != null && v > m ? v : m), 0); + // The panel's headline number is the window total (the per-bucket latest is + // often 0 for the current partial bucket). + const total = s.values.reduce<number>((sum, v) => sum + (v ?? 0), 0); + const isSymlog = scale === "symlog"; + const domainMax = Math.max(10, max); + + return ( + <div className="rounded-lg border border-[var(--border)] p-3"> + <div className="mb-1 flex items-baseline justify-between gap-2"> + <span className="flex items-center gap-1.5 truncate text-sm text-[var(--foreground)]"> + <span + className="inline-block h-2 w-2 shrink-0 rounded-[2px]" + style={{ backgroundColor: s.color }} + /> + {s.label} + </span> + <span className="font-mono text-xs tabular-nums text-[var(--muted-foreground)]"> + {compactNumber(Math.round(total))} + </span> + </div> + <ChartContainer config={config} className="aspect-auto h-[120px] w-full"> + <AreaChart data={rows} margin={{ top: 4, right: 4, bottom: 0, left: 0 }} accessibilityLayer> + <YAxis + type="number" + hide + scale={isSymlog ? (symlog as unknown as "linear") : undefined} + domain={isSymlog ? [0, domainMax] : [0, "auto"]} + ticks={isSymlog ? symlogTicks(domainMax) : undefined} + allowDecimals={false} + /> + <Area + dataKey="v" + type="monotone" + stroke={s.color} + fill={s.color} + fillOpacity={0.18} + strokeWidth={1.5} + connectNulls + isAnimationActive={false} + /> + </AreaChart> + </ChartContainer> + </div> + ); +} diff --git a/common/lib/homepageChart.ts b/common/lib/homepageChart.ts @@ -49,3 +49,210 @@ export function dayLabel(yyyymmdd: string): string { 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 = "momentum" | "growth" | "leaderboard" | "compare"; + +// 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<string>; + soloId: string | null; // isolate one series (overrides hidden) + excludeTop: boolean; // drop the largest site and rescale (site breakdown only) +}; + +export const PRESETS: Record< + PresetId, + { label: string; hint: string; state: PresetState } +> = { + momentum: { + label: "Momentum", + hint: "Recent activity, each site on a symlog axis", + state: { + metric: "transcribed", + breakdown: "site", + chartType: "line", + bucket: "day", + range: "90d", + scale: "symlog", + valueMode: "counts", + }, + }, + growth: { + label: "Growth", + hint: "The archive accumulating over all time", + state: { + metric: "transcribed", + breakdown: "site", + chartType: "area", + bucket: "cumulative", + range: "all", + scale: "linear", + valueMode: "counts", + }, + }, + leaderboard: { + label: "Leaderboard", + hint: "Top channels ranked by transcripts", + state: { + metric: "transcribed", + breakdown: "channel", + chartType: "barH", + bucket: "day", // ignored by barH (it sums over the range) + range: "all", + scale: "symlog", + valueMode: "counts", + }, + }, + compare: { + label: "Compare", + hint: "Each site at its own independent scale", + state: { + metric: "transcribed", + breakdown: "site", + chartType: "small", + bucket: "week", + range: "all", + scale: "linear", + valueMode: "counts", + }, + }, +}; + +export const PRESET_ORDER: PresetId[] = [ + "momentum", + "growth", + "leaderboard", + "compare", +]; + +export const DEFAULT_STATE: ChartState = { + ...PRESETS.momentum.state, + preset: "momentum", + 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>, +): 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; +} diff --git a/common/lib/homepageChartData.ts b/common/lib/homepageChartData.ts @@ -0,0 +1,223 @@ +// 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"; + +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<ChartState["range"], Record<Gran, number | null>> = { + "2w": { day: 14, week: 2, month: 1 }, + "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). +function entries( + summary: HomepageSummary, + state: ChartState, + src: BucketSeries, + len: number, + start: number, +): Entry[] { + if (state.breakdown === "site") { + return summary.sites.map((s, i) => ({ + id: s.siteId, + label: s.siteTitle, + color: seriesColor(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)[] => { + const arr = cumulative || indexed ? runningSum(full) : full; + const win = arr.slice(start); + if (indexed) { + const base = win.find((v) => v > 0) ?? 0; + return win.map((v) => + base <= 0 || v < base ? null : (v / base) * 100, + ); + } + return win; + }; + + 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. Colors are +// re-keyed by rank so the leader is chart-1, and the long tail is truncated. +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, + value: windowTotal(e.full, len, start), + })) + .sort((a, b) => b.value - a.value || a.label.localeCompare(b.label)); + const rows: RankRow[] = list + .slice(0, topN) + .map((r, i) => ({ ...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; +} diff --git a/common/package.json b/common/package.json @@ -8,6 +8,7 @@ "@tanstack/react-query": "^5.99.1", "@tanstack/react-virtual": "^3.13.0", "clsx": "^2.1.1", + "d3-scale": "^4.0.2", "execa": "^9.6.1", "fs-extra": "^11.3.4", "lmdb": "^3.5.4", @@ -24,6 +25,7 @@ "react-dom": "19.2.4" }, "devDependencies": { + "@types/d3-scale": "^4.0.9", "@types/fs-extra": "^11.0.4", "@types/node": "^20.19.39", "@types/react": "^19.2.14", diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md @@ -1,5 +1,38 @@ # Homepage Changelog +## 2026-06-29 + +- **Charts rebuilt around presets + a power-user explorer.** The single five-control + chart is replaced by named **preset chips** that each set a full, data-tuned view, + with an inline **"Customize"** disclosure exposing the raw matrix (Metric / + Breakdown / Chart type / Bucket / Range / Scale / Values + an exclude-outlier + toggle). Selecting a preset sets the controls; touching any control reconciles the + state (chart type is authoritative — Symlog is greyed on a stacked chart, Share on a + non-stacked one) and lights the **Custom** chip. The four presets: + - **Momentum** (default) — one line per site over 90 days on a **symlog** y-axis, so + the small sites (Rekietalyzer, Hasanalyzer) stay legible next to Jeralyzer instead + of flat-lining; symlog (not log) because the daily series is full of zeros. + - **Growth** — cumulative stacked area over all time (the 0 → ~29k archive story). + - **Leaderboard** — top-12 channels as ranked horizontal bars on a symlog value axis. + - **Compare** — small multiples, one mini-panel per site at its **own** scale. +- **Skew-handling tools for the one-dominant-site data.** Beyond the symlog scale + toggle and small multiples, Customize adds **Indexed** (every series rebased to 100 + at its window start — % growth, composes with symlog so the multiples stay + comparable) and **Exclude <top site>** (drops the leader and rescales). Clicking a + line isolates it. The default view is now Momentum · 90d (the old Day · 2-week + default landed on June, the emptiest slice). +- **"Instrument panel" chart styling.** The plot sits on an opaque, slightly-lifted + surface (`.panel-instrument`) that occludes the page glow/grain for data contrast, + with crisp themed gridlines/axes and a hairline brass top edge; the in-panel brass + glow is gone. Brass identity stays in the preset chips, the active control state, and + the series palette. +- New shared, framework-free pipeline (`common/lib/homepageChartData.ts`) feeds every + chart (`common/components/charts/{ChartPanel,MomentumChart,SmallMultiples, + LeaderboardChart,CrossSiteChart}.tsx`); symlog uses d3 `scaleSymlog` (added `d3-scale` + to `common`). New homepage Playwright suite (`e2e/landing.spec.ts`) covers every + preset, the symlog small-site visibility, the guard rails, exclude-outlier, indexed, + and mobile. No summary-schema change — everything derives client-side. + ## 2026-06-28 - **The home page is now the cross-site landing.** The thin branding hero and the diff --git a/homepage/app/components/CustomizePanel.tsx b/homepage/app/components/CustomizePanel.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState } from "react"; +import { + isDisabled, + type ChartState, +} from "yt-dlp-transcript-common/lib/homepageChart"; +import { Segmented } from "./Segmented"; + +// The power-user matrix, hidden behind a disclosure. Each control is a Segmented +// group; invalid options are greyed via isDisabled (e.g. Symlog is unavailable +// on a stacked chart, Share on a non-stacked one) so the UI shows *why* rather +// than silently flipping a choice. The exclude-outlier toggle drops the top site. +export function CustomizePanel({ + state, + onPatch, + topSiteLabel, +}: { + state: ChartState; + onPatch: (patch: Partial<ChartState>) => void; + topSiteLabel: string | null; +}) { + const [open, setOpen] = useState(false); + const dis = (key: Parameters<typeof isDisabled>[0], value: string) => + isDisabled(key, value, state); + + return ( + <div className="flex flex-col gap-3"> + <button + type="button" + aria-expanded={open} + onClick={() => setOpen((o) => !o)} + className="inline-flex w-fit items-center gap-1.5 font-mono text-[0.6875rem] uppercase tracking-[0.18em] text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]" + > + <span className="text-[var(--accent)]">{open ? "–" : "+"}</span> + Customize + </button> + + {open && ( + <div className="flex flex-wrap items-center gap-2.5"> + <Segmented + label="Metric" + value={state.metric} + onChange={(v) => onPatch({ metric: v })} + options={[ + { value: "transcribed", label: "Transcribed" }, + { value: "downloaded", label: "Downloaded" }, + ]} + /> + <Segmented + label="Breakdown" + value={state.breakdown} + onChange={(v) => onPatch({ breakdown: v })} + options={[ + { value: "site", label: "By site" }, + { value: "channel", label: "By channel" }, + ]} + /> + <Segmented + label="Chart type" + value={state.chartType} + onChange={(v) => onPatch({ chartType: v })} + options={[ + { value: "line", label: "Lines" }, + { value: "area", label: "Area" }, + { value: "bar", label: "Bars" }, + { value: "barH", label: "Ranked" }, + { value: "small", label: "Small" }, + ]} + /> + <Segmented + label="Bucket" + value={state.bucket} + onChange={(v) => onPatch({ bucket: v })} + options={[ + { value: "day", label: "Day" }, + { value: "week", label: "Week" }, + { value: "month", label: "Month" }, + { value: "cumulative", label: "Cumulative" }, + ]} + /> + <Segmented + label="Range" + value={state.range} + onChange={(v) => onPatch({ range: v })} + options={[ + { value: "2w", label: "2w" }, + { value: "90d", label: "90d" }, + { value: "12mo", label: "12mo" }, + { value: "all", label: "All" }, + ]} + /> + <Segmented + label="Scale" + value={state.scale} + onChange={(v) => onPatch({ scale: v })} + options={[ + { value: "linear", label: "Linear" }, + { value: "symlog", label: "Symlog", disabled: dis("scale", "symlog") }, + ]} + /> + <Segmented + label="Values" + value={state.valueMode} + onChange={(v) => onPatch({ valueMode: v })} + options={[ + { value: "counts", label: "Counts" }, + { value: "share", label: "Share", disabled: dis("valueMode", "share") }, + { value: "indexed", label: "Indexed", disabled: dis("valueMode", "indexed") }, + ]} + /> + {topSiteLabel && ( + <button + type="button" + aria-pressed={state.excludeTop} + onClick={() => onPatch({ excludeTop: !state.excludeTop })} + className={ + "rounded-full border px-3 py-1.5 text-[0.8125rem] font-medium transition-all duration-150 " + + (state.excludeTop + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent-strong)]" + : "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)] hover:text-[var(--foreground)]") + } + > + {state.excludeTop ? `Excluding ${topSiteLabel}` : `Exclude ${topSiteLabel}`} + </button> + )} + </div> + )} + </div> + ); +} diff --git a/homepage/app/components/HomeLanding.tsx b/homepage/app/components/HomeLanding.tsx @@ -1,76 +1,50 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import type { HomepageSummary } from "yt-dlp-transcript-common/lib/homepageSummary"; -import type { - Breakdown, - Bucket, - Range, - Metric, - Display, +import { + DEFAULT_STATE, + applyPreset, + applyPatch, + type ChartState, + type PresetId, } from "yt-dlp-transcript-common/lib/homepageChart"; -import { CrossSiteChart } from "yt-dlp-transcript-common/components/charts/CrossSiteChart"; +import { topSite } from "yt-dlp-transcript-common/lib/homepageChartData"; +import { ChartPanel } from "yt-dlp-transcript-common/components/charts/ChartPanel"; import { KpiHeader } from "./KpiHeader"; import { SiteGrid } from "./SiteGrid"; +import { PresetBar } from "./PresetBar"; +import { CustomizePanel } from "./CustomizePanel"; -// A compact segmented control — the "instrument panel" driving the single -// landing chart. Touch-friendly and wraps on small screens. -function Segmented<T extends string>({ - label, - value, - options, - onChange, -}: { - label: string; - value: T; - options: { value: T; label: string }[]; - onChange: (v: T) => void; -}) { - return ( - <div - role="group" - aria-label={label} - className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--panel)] p-0.5 backdrop-blur-sm" - > - {options.map((o) => { - const active = o.value === value; - return ( - <button - key={o.value} - type="button" - aria-pressed={active} - onClick={() => onChange(o.value)} - className={ - "rounded-full px-3 py-1.5 text-[0.8125rem] font-medium transition-all duration-150 " + - (active - ? "bg-[var(--accent-soft)] text-[var(--accent-strong)] shadow-[inset_0_0_0_1px_var(--accent-soft)]" - : "text-[var(--muted-foreground)] hover:text-[var(--foreground)]") - } - > - {o.label} - </button> - ); - })} - </div> - ); -} - +// The cross-site landing: headline KPIs, then one chart driven by named presets +// (with an inline Customize matrix for power users), then the site grid. All of +// it is one ChartState — presets set a full valid state; touching a control +// reconciles + recomputes which preset (or "custom") we're on. export function HomeLanding({ summary }: { summary: HomepageSummary }) { - const [breakdown, setBreakdown] = useState<Breakdown>("site"); - const [bucket, setBucket] = useState<Bucket>("day"); - const [range, setRange] = useState<Range>("2w"); - const [metric, setMetric] = useState<Metric>("transcribed"); - const [display, setDisplay] = useState<Display>("counts"); - const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set()); + const [state, setState] = useState<ChartState>(DEFAULT_STATE); + const selectPreset = useCallback( + (id: PresetId) => setState((p) => applyPreset(p, id)), + [], + ); + const patch = useCallback( + (p: Partial<ChartState>) => setState((s) => applyPatch(s, p)), + [], + ); const toggleSite = useCallback((siteId: string) => { - setHidden((prev) => { - const next = new Set(prev); + setState((s) => { + const next = new Set(s.hidden); if (next.has(siteId)) next.delete(siteId); else next.add(siteId); - return next; + return applyPatch(s, { hidden: next }); }); }, []); + // Click a line to isolate it (click again to clear). + const selectSeries = useCallback((id: string) => { + setState((s) => applyPatch(s, { soloId: s.soloId === id ? null : id })); + }, []); + + const top = useMemo(() => topSite(summary, state), [summary, state]); return ( <div className="flex flex-col gap-14 sm:gap-20"> @@ -79,73 +53,22 @@ export function HomeLanding({ summary }: { summary: HomepageSummary }) { </div> <section - className="reveal flex flex-col gap-5" + className="reveal flex flex-col gap-4" style={{ "--d": "200ms" } as React.CSSProperties} > - <div className="flex flex-wrap items-center gap-2.5"> - <Segmented - label="Metric" - value={metric} - onChange={setMetric} - options={[ - { value: "transcribed", label: "Transcribed" }, - { value: "downloaded", label: "Downloaded" }, - ]} - /> - <Segmented - label="Breakdown" - value={breakdown} - onChange={setBreakdown} - options={[ - { value: "site", label: "By site" }, - { value: "channel", label: "By channel" }, - ]} - /> - <Segmented - label="Bucket" - value={bucket} - onChange={setBucket} - options={[ - { value: "day", label: "Day" }, - { value: "week", label: "Week" }, - { value: "month", label: "Month" }, - { value: "cumulative", label: "Cumulative" }, - ]} - /> - <Segmented - label="Range" - value={range} - onChange={setRange} - options={[ - { value: "2w", label: "2w" }, - { value: "90d", label: "90d" }, - { value: "12mo", label: "12mo" }, - { value: "all", label: "All" }, - ]} - /> - <Segmented - label="Display" - value={display} - onChange={setDisplay} - options={[ - { value: "counts", label: "Counts" }, - { value: "share", label: "Share" }, - ]} - /> - </div> - - <div className="relative overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--panel)] p-3 pt-5 backdrop-blur-sm sm:p-5"> - <div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-[var(--accent-soft)] to-transparent opacity-40" /> - <CrossSiteChart + <PresetBar active={state.preset} onSelect={selectPreset} /> + <div className="panel-instrument rounded-2xl p-3 pt-4 sm:p-5"> + <ChartPanel summary={summary} - breakdown={breakdown} - bucket={bucket} - range={range} - metric={metric} - display={display} - hidden={hidden} + state={state} + onSelectSeries={selectSeries} /> </div> + <CustomizePanel + state={state} + onPatch={patch} + topSiteLabel={top?.label ?? null} + /> </section> <section @@ -154,9 +77,9 @@ export function HomeLanding({ summary }: { summary: HomepageSummary }) { > <SiteGrid sites={summary.sites} - metric={metric} - breakdown={breakdown} - hidden={hidden} + metric={state.metric} + breakdown={state.breakdown} + hidden={state.hidden} onToggle={toggleSite} /> </section> diff --git a/homepage/app/components/PresetBar.tsx b/homepage/app/components/PresetBar.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { + PRESET_ORDER, + PRESETS, + type PresetId, +} from "yt-dlp-transcript-common/lib/homepageChart"; + +// The primary control: named preset chips that each set a full, valid chart +// state. A "Custom" chip lights up once any Customize control diverges from a +// preset. +export function PresetBar({ + active, + onSelect, +}: { + active: PresetId | "custom"; + onSelect: (id: PresetId) => void; +}) { + return ( + <div role="group" aria-label="Chart preset" className="flex flex-wrap items-center gap-2"> + {PRESET_ORDER.map((id) => { + const on = active === id; + return ( + <button + key={id} + type="button" + aria-pressed={on} + title={PRESETS[id].hint} + onClick={() => onSelect(id)} + className={ + "rounded-full border px-4 py-2 text-sm font-medium transition-all duration-150 " + + (on + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent-strong)]" + : "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)] hover:text-[var(--foreground)]") + } + > + {PRESETS[id].label} + </button> + ); + })} + {active === "custom" && ( + <span className="rounded-full border border-dashed border-[var(--border-strong)] px-4 py-2 text-sm text-[var(--faint)]"> + Custom + </span> + )} + </div> + ); +} diff --git a/homepage/app/components/Segmented.tsx b/homepage/app/components/Segmented.tsx @@ -0,0 +1,50 @@ +"use client"; + +// A compact segmented control — the unit the Customize matrix is built from. +// Touch-friendly, wraps on small screens, and can grey out individual options +// (a disabled option that happens to be active still shows, just unclickable). +export function Segmented<T extends string>({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: { value: T; label: string; disabled?: boolean }[]; + onChange: (v: T) => void; +}) { + return ( + <div + role="group" + aria-label={label} + className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--panel)] p-0.5 backdrop-blur-sm" + > + {options.map((o) => { + const active = o.value === value; + const disabled = !!o.disabled && !active; + return ( + <button + key={o.value} + type="button" + aria-pressed={active} + disabled={disabled} + onClick={() => { + if (!disabled) onChange(o.value); + }} + className={ + "rounded-full px-3 py-1.5 text-[0.8125rem] font-medium transition-all duration-150 " + + (active + ? "bg-[var(--accent-soft)] text-[var(--accent-strong)] shadow-[inset_0_0_0_1px_var(--accent-soft)]" + : disabled + ? "cursor-not-allowed text-[var(--faint)] opacity-50" + : "text-[var(--muted-foreground)] hover:text-[var(--foreground)]") + } + > + {o.label} + </button> + ); + })} + </div> + ); +} diff --git a/homepage/app/globals.css b/homepage/app/globals.css @@ -30,6 +30,12 @@ --chart-3: #f2935b; --chart-4: #c08cf0; --chart-5: #f178b6; + /* "Instrument panel": an opaque, slightly-lifted plot surface that occludes + the page glow/grain for maximum data contrast, plus crisp axis/grid inks. */ + --chart-surface: #16110a; + --chart-grid: rgba(233, 220, 197, 0.08); + --chart-axis: #8a8170; + --chart-tooltip-bg: #1b150d; } @theme inline { @@ -92,6 +98,17 @@ body::after { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } +/* The chart "instrument panel": opaque so the atmospheric glow/grain stops at + the plot edge (the data zone reads as a calm instrument, not the warm room), + with a hairline brass-tinted top edge so brass still signs the panel. No + backdrop-blur — the surface is solid by design. */ +.panel-instrument { + background: var(--chart-surface); + border: 1px solid var(--border-strong); + border-top-color: var(--accent-soft); + box-shadow: inset 0 1px 0 rgba(233, 220, 197, 0.04); +} + /* A faint hairline grid layer the page can opt into for the "ledger" feel. */ .grid-ledger { background-image: diff --git a/homepage/e2e/landing.spec.ts b/homepage/e2e/landing.spec.ts @@ -0,0 +1,90 @@ +import { test, expect, type Page } from "@playwright/test"; + +// The landing's cross-site chart: presets, the symlog skew-handling, and the +// Customize guard rails. Asserts against the live recharts SVG. + +const pressedPreset = (page: Page) => + page.locator('[aria-label="Chart preset"] button[aria-pressed="true"]'); + +const yTicks = (page: Page) => + page + .locator(".recharts-yAxis .recharts-cartesian-axis-tick-value") + .allTextContents(); + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForSelector(".recharts-surface"); +}); + +test("Momentum is the default: symlog lines keep the small sites visible", async ({ + page, +}) => { + await expect(pressedPreset(page)).toHaveText("Momentum"); + await expect(page.locator(".recharts-line-curve")).toHaveCount(3); + + const ticks = (await yTicks(page)).map((t) => t.trim()); + for (const t of ["0", "10", "100", "1K", "10K"]) expect(ticks).toContain(t); + + // The two small sites must lift off the floor (symlog), not lie flat. + const heights = await page.evaluate(() => + [...document.querySelectorAll(".recharts-line-curve")].map( + (p) => (p as SVGPathElement).getBBox().height, + ), + ); + expect(Math.min(heights[1], heights[2])).toBeGreaterThan(25); +}); + +test("each preset renders its own chart type", async ({ page }) => { + await page.getByRole("button", { name: "Growth", exact: true }).click(); + await expect(page.locator(".recharts-area-area")).toHaveCount(3); + + await page.getByRole("button", { name: "Leaderboard", exact: true }).click(); + await expect(page.locator(".recharts-bar-rectangle")).toHaveCount(12); + + await page.getByRole("button", { name: "Compare", exact: true }).click(); + await expect(page.locator(".recharts-responsive-container")).toHaveCount(3); +}); + +test("Customize greys invalid option combinations", async ({ page }) => { + await page.getByRole("button", { name: "Customize" }).click(); + // Line chart (Momentum default): Share invalid, Symlog valid. + await expect(page.getByRole("button", { name: "Share", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Symlog", exact: true })).toBeEnabled(); + // Switch to a stacked Area: Symlog invalid, Share valid. + await page.getByRole("button", { name: "Area", exact: true }).click(); + await expect(page.getByRole("button", { name: "Symlog", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Share", exact: true })).toBeEnabled(); +}); + +test("exclude-outlier drops the leading site", async ({ page }) => { + await page.getByRole("button", { name: "Customize" }).click(); + await expect(page.locator(".recharts-line-curve")).toHaveCount(3); + await page.getByRole("button", { name: /^Exclude / }).click(); + await expect(page.locator(".recharts-line-curve")).toHaveCount(2); +}); + +test("Indexed rebases every series to a shared baseline", async ({ page }) => { + await page.getByRole("button", { name: "Customize" }).click(); + await page.getByRole("button", { name: "Indexed", exact: true }).click(); + // No preset matches a custom state. + await expect(pressedPreset(page)).toHaveCount(0); + // Every line begins at the same y-pixel (value 100). + const ys = await page.evaluate(() => + [...document.querySelectorAll(".recharts-line-curve")] + .map((p) => { + const m = (p.getAttribute("d") || "").match( + /^M\s*[-\d.]+[, ]\s*([-\d.]+)/, + ); + return m ? parseFloat(m[1]) : null; + }) + .filter((v): v is number => v != null), + ); + expect(ys.length).toBeGreaterThanOrEqual(2); + expect(Math.max(...ys) - Math.min(...ys)).toBeLessThan(2); +}); + +test("mobile: small multiples stack to one column", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 800 }); + await page.getByRole("button", { name: "Compare", exact: true }).click(); + await expect(page.locator(".recharts-responsive-container")).toHaveCount(3); +}); diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Homepage e2e. Runs against `next dev` (default mode) so it reflects uncommitted +// source — the landing reads its build-time summary from public/ on disk, which +// is present in dev. Kill any stale dev server on the port between runs. +const PORT = Number(process.env.PORT ?? 3032); +const baseURL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + outputDir: "test-results/", + fullyParallel: false, + workers: 1, + webServer: { + command: `pnpm exec next dev --port ${PORT}`, + url: baseURL, + timeout: 120_000, + reuseExistingServer: !process.env.CI, + }, + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"], viewport: { width: 1100, height: 1100 } }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + d3-scale: + specifier: ^4.0.2 + version: 4.0.2 execa: specifier: ^9.6.1 version: 9.6.1 @@ -63,6 +66,9 @@ importers: specifier: ^3.6.0 version: 3.6.0 devDependencies: + '@types/d3-scale': + specifier: ^4.0.9 + version: 4.0.9 '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4