Archilyzer · Source

archilyzer

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

commit e08d033d328fdd7f5ae9ecd40ce522ed4e53fcb9
parent df5f33f38301aa839aa8c2b52a5aeee1d7f9569a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 29 Jun 2026 09:16:07 -0400

Rework chart presets: recent, site-based, combined; fix slam + render bug

Feedback: Growth (all-time cumulative stacked area) was still slammed by
Jeralyzer, and Compare (small multiples) was confusing. Presets should always
be per-site, put all sites in one combined chart, and use smaller slices so
the May megaspike is excluded.

Replace the four presets with three site-based combined line charts, and add
a spike-excluding "6w" range:
- Recent (default): per-site lines, weekly, last 6 weeks, linear. In that
  window the three sites are comparable (~290/420/440 vs all-time 27.5k/710/
  500), so a plain linear chart reads clearly with the spike excluded.
- Cumulative: window-relative running totals over the recent window (starts at
  0 at the window edge, so it isn't dominated by a site's historical total).
- All-time: weekly on a symlog axis — the one tamed historical view.

The full toolbox (by-channel, ranked, small multiples, share/symlog/indexed,
exclude-outlier) stays in Customize.

Fixes:
- homepageChartData: cumulative is now window-relative (slice then running-sum)
  so a recent slice isn't slammed by Jeralyzer's ~27k baseline.
- MomentumChart: pass an explicit YAxis scale ("linear") instead of undefined —
  recharts 2.15.4 collapses an undefined scale into a 2-value categorical axis,
  which dropped every mid-valued point from linear line charts.

Verified: typecheck (common + homepage), static build, and the rewritten
Playwright suite (7/7) covering the three presets, All-time symlog, the
Customize toolbox + guard rails, exclude-outlier, indexed, and mobile.

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

Diffstat:
Mcommon/components/charts/MomentumChart.tsx | 6+++---
Mcommon/lib/homepageChart.ts | 66+++++++++++++++++++++++++++---------------------------------------
Mcommon/lib/homepageChartData.ts | 17++++++++++-------
Mhomepage/CHANGELOG.md | 31++++++++++++++++++-------------
Mhomepage/app/components/CustomizePanel.tsx | 1+
Mhomepage/e2e/landing.spec.ts | 69++++++++++++++++++++++++++++++++++++++++++---------------------------
6 files changed, 101 insertions(+), 89 deletions(-)

diff --git a/common/components/charts/MomentumChart.tsx b/common/components/charts/MomentumChart.tsx @@ -89,8 +89,8 @@ export function MomentumChart({ <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} + scale={isSymlog ? (symlog as unknown as "linear") : "linear"} + domain={[0, domainMax]} ticks={ticks} tickFormatter={(v: number) => compactNumber(v)} tickLine={false} @@ -105,7 +105,7 @@ export function MomentumChart({ key={s.id} dataKey={s.id} name={s.label} - type="monotone" + type="linear" stroke={s.color} strokeWidth={1.75} dot={false} diff --git a/common/lib/homepageChart.ts b/common/lib/homepageChart.ts @@ -8,7 +8,7 @@ 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" | "90d" | "12mo" | "all"; +export type Range = "2w" | "6w" | "90d" | "12mo" | "all"; export type Metric = MetricKey; // "transcribed" | "downloaded" export type Display = "share" | "counts"; @@ -65,7 +65,7 @@ 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"; +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.) @@ -86,74 +86,62 @@ export type ChartState = PresetState & { 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 } > = { - momentum: { - label: "Momentum", - hint: "Recent activity, each site on a symlog axis", + recent: { + label: "Recent", + hint: "The last six weeks, week by week", state: { metric: "transcribed", breakdown: "site", chartType: "line", - bucket: "day", - range: "90d", - scale: "symlog", + bucket: "week", + range: "6w", + scale: "linear", valueMode: "counts", }, }, - growth: { - label: "Growth", - hint: "The archive accumulating over all time", + cumulative: { + label: "Cumulative", + hint: "Running totals over the last six weeks", state: { metric: "transcribed", breakdown: "site", - chartType: "area", + chartType: "line", bucket: "cumulative", - range: "all", + range: "6w", 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", + alltime: { + label: "All-time", + hint: "Every week since launch, on a symlog axis", state: { metric: "transcribed", breakdown: "site", - chartType: "small", + chartType: "line", bucket: "week", range: "all", - scale: "linear", + scale: "symlog", valueMode: "counts", }, }, }; -export const PRESET_ORDER: PresetId[] = [ - "momentum", - "growth", - "leaderboard", - "compare", -]; +export const PRESET_ORDER: PresetId[] = ["recent", "cumulative", "alltime"]; export const DEFAULT_STATE: ChartState = { - ...PRESETS.momentum.state, - preset: "momentum", + ...PRESETS.recent.state, + preset: "recent", hidden: new Set(), soloId: null, excludeTop: false, diff --git a/common/lib/homepageChartData.ts b/common/lib/homepageChartData.ts @@ -19,6 +19,7 @@ type Gran = "day" | "week" | "month"; // day series; week/month on their own). const WINDOW: Record<ChartState["range"], Record<Gran, number | null>> = { "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 }, @@ -155,15 +156,17 @@ export function buildTimeSeries( 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); + // Indexed rebases the absolute running total to 100 at the window start. if (indexed) { - const base = win.find((v) => v > 0) ?? 0; - return win.map((v) => - base <= 0 || v < base ? null : (v / base) * 100, - ); + 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)); } - return win; + // 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) => ({ diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md @@ -8,19 +8,24 @@ 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). + non-stacked one) and lights the **Custom** chip. The presets are deliberately simple — + **always per-site, always one combined line chart** — and lean on a recent window so + the May megaspike (15.8k in a day, Jeralyzer's backfill) doesn't dominate. The three: + - **Recent** (default) — one line per site over the last six weeks, weekly, linear. + In that window the three sites are *comparable* (recent totals ~290/420/440 vs + all-time 27.5k/710/500), so a plain combined line chart reads clearly with no site + slammed and the spike excluded. + - **Cumulative** — running totals over the same recent window. The running sum is + **window-relative** (starts at 0 at the window edge), so it isn't dominated by a + site's historical total the way an absolute cumulative is. + - **All-time** — every week since launch on a **symlog** y-axis: the one tamed + historical view, where symlog keeps the dominant site from slamming the small ones. +- **Skew-handling tools for the one-dominant-site data.** The full toolbox stays in + Customize: by-channel, ranked leaderboard, small multiples, the linear⇄symlog toggle, + 100% **Share**, **Indexed** (every series rebased to 100 at its window start), and + **Exclude <top site>** (drops the leader and rescales). Clicking a line isolates it. + (Fixed a recharts quirk where a conditionally-`undefined` y-axis `scale` collapsed + linear line charts to two points — the scale is now always explicit.) - **"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 diff --git a/homepage/app/components/CustomizePanel.tsx b/homepage/app/components/CustomizePanel.tsx @@ -85,6 +85,7 @@ export function CustomizePanel({ onChange={(v) => onPatch({ range: v })} options={[ { value: "2w", label: "2w" }, + { value: "6w", label: "6w" }, { value: "90d", label: "90d" }, { value: "12mo", label: "12mo" }, { value: "all", label: "All" }, diff --git a/homepage/e2e/landing.spec.ts b/homepage/e2e/landing.spec.ts @@ -1,7 +1,8 @@ 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. +// The landing's cross-site chart: the three site-based presets, plus the +// power-user Customize toolbox (chart types, scale, exclude-outlier, indexed) +// and its guard rails. Asserts against the live recharts SVG. const pressedPreset = (page: Page) => page.locator('[aria-label="Chart preset"] button[aria-pressed="true"]'); @@ -11,43 +12,58 @@ const yTicks = (page: Page) => .locator(".recharts-yAxis .recharts-cartesian-axis-tick-value") .allTextContents(); +const fullLines = (page: Page) => + // Lines that actually span the data (guards against the scale-undefined glitch + // that collapsed lines to 1-2 points). + page.evaluate( + () => + [...document.querySelectorAll(".recharts-line-curve")].filter( + (p) => (p.getAttribute("d") || "").length > 30, + ).length, + ); + 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 ({ +test("Recent is the default: three full site lines on a linear axis", async ({ page, }) => { - await expect(pressedPreset(page)).toHaveText("Momentum"); + await expect(pressedPreset(page)).toHaveText("Recent"); await expect(page.locator(".recharts-line-curve")).toHaveCount(3); + expect(await fullLines(page)).toBe(3); + // Linear (not symlog): no "1K"/"10K" decade ticks. + const ticks = (await yTicks(page)).map((t) => t.trim()); + expect(ticks).not.toContain("10K"); +}); +test("all three presets draw site lines; All-time is symlog", async ({ page }) => { + for (const name of ["Recent", "Cumulative", "All-time"]) { + await page.getByRole("button", { name, exact: true }).click(); + await expect(pressedPreset(page)).toHaveText(name); + await expect(page.locator(".recharts-line-curve")).toHaveCount(3); + expect(await fullLines(page)).toBe(3); + } + // All-time is the tamed symlog view. 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 still reaches the full toolbox (ranked / small / channel)", async ({ + page, +}) => { + await page.getByRole("button", { name: "Customize" }).click(); + await page.getByRole("button", { name: "By channel", exact: true }).click(); + await page.getByRole("button", { name: "Ranked", exact: true }).click(); + await expect(page.locator(".recharts-bar-rectangle").first()).toBeVisible(); + await page.getByRole("button", { name: "Small", exact: true }).click(); + await expect(page.locator(".recharts-responsive-container").first()).toBeVisible(); }); test("Customize greys invalid option combinations", async ({ page }) => { await page.getByRole("button", { name: "Customize" }).click(); - // Line chart (Momentum default): Share invalid, Symlog valid. + // Default Recent is a line chart: 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. @@ -56,7 +72,7 @@ test("Customize greys invalid option combinations", async ({ page }) => { await expect(page.getByRole("button", { name: "Share", exact: true })).toBeEnabled(); }); -test("exclude-outlier drops the leading site", async ({ page }) => { +test("exclude-outlier drops a site line", 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(); @@ -66,9 +82,7 @@ test("exclude-outlier drops the leading site", async ({ page }) => { 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). + await expect(pressedPreset(page)).toHaveCount(0); // custom const ys = await page.evaluate(() => [...document.querySelectorAll(".recharts-line-curve")] .map((p) => { @@ -85,6 +99,7 @@ test("Indexed rebases every series to a shared baseline", async ({ page }) => { 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 page.getByRole("button", { name: "Customize" }).click(); + await page.getByRole("button", { name: "Small", exact: true }).click(); await expect(page.locator(".recharts-responsive-container")).toHaveCount(3); });