From 26a859e975bfa261da19b11b7a5513b19bcee350 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Fri, 19 Jun 2026 22:35:49 -0400 Subject: [PATCH 01/10] fix(web): color charts by format/engine, date x-axis, ms y-axis, subtler grid Match the v2 frontend's more-readable chart presentation, per review feedback: - Series colors come from FORMAT (hue) + ENGINE (shade) -- Vortex is always a green, Parquet orange, etc. -- via colorForSeries(), reproducing the v2 SERIES_COLOR_MAP exactly for known series with a format-hue fallback so any Vortex series stays green. Identity-based, so colors are stable across the ?n=all reshape without the old index-based assignStableColors machinery. - X-axis shows datetime labels (Jun 19, 2026) instead of commit SHAs. - Y-axis time unit caps at ms; a slow benchmark no longer auto-promotes to seconds, keeping time axes comparable in one unit. - Grid lines use a subtle slate color instead of the heavier Chart.js default. Adds colorForSeries/commitDateLabel tests; updates the ms-cap unit tests. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Connor Tsui --- web/components/Chart.tsx | 52 ++++-------- web/lib/chart-format.test.ts | 57 ++++++++++++- web/lib/chart-format.ts | 159 +++++++++++++++++++++++++++++++++-- 3 files changed, 224 insertions(+), 44 deletions(-) diff --git a/web/components/Chart.tsx b/web/components/Chart.tsx index f84f65c..3d7b340 100644 --- a/web/components/Chart.tsx +++ b/web/components/Chart.tsx @@ -15,11 +15,11 @@ import type { } from 'chart.js'; import { - assignStableColors, CHART_FETCH_N, clampRangeWindow, collectAllValues, - colorFor, + colorForSeries, + commitDateLabel, decimateSeries, DEFAULT_VISIBLE, escapeHtml, @@ -31,7 +31,6 @@ import { HOVER_PREFETCH_PRIORITY, IDENTITY_UNIT, INTERACTION_FULL_PRIORITY, - labelForCommit, LAZY_HYDRATION_ROOT_MARGIN, MAX_VISIBLE_POINTS, normalizeChartPayload, @@ -117,10 +116,6 @@ interface CardState { /** Series labels the user has explicitly legend-toggled on this card. Once * set, the global/group filters no longer drive that label here. */ overrides: Record; - /** Stable per-series colors by label. Series are colored by first-seen order - * (see [`assignStableColors`]) so the `?n=all` upgrade surfacing older series - * never reshuffles the colors of series already on the chart. */ - seriesColors: Map; displayUnit: DisplayUnit; /** v3 also tracked `__bench_inline_trimmed`; it was write-only there and is * dropped here. `payload.history.complete` carries the same information. */ @@ -175,6 +170,11 @@ interface CardCallbacks { setConstructed: (on: boolean) => void; } +/** Subtle, theme-neutral grid line color (slate-gray at low opacity), matching + * the v2 frontend's faint gridlines a reviewer preferred over the heavier + * Chart.js default. */ +const GRID_COLOR = 'rgba(148, 163, 184, 0.14)'; + // --------------------------------------------------------------------------- // Crosshair plugin: draws a vertical line at the chart's active hover index. // An inline plugin is cheaper than chartjs-plugin-crosshair, which is overkill @@ -220,22 +220,19 @@ function benchDatasets(chart: ChartJs): BenchDataset[] { * visible range. `rawData` holds a reference to the original payload so the * tooltip can show raw values regardless of LTTB. */ -function buildDatasets( - payload: NormalizedChartPayload, - colors: ReadonlyMap, -): BenchDataset[] { +function buildDatasets(payload: NormalizedChartPayload): BenchDataset[] { const raw = payload.series ?? {}; const meta = payload.series_meta ?? {}; const n = payload.commits.length; const global = getGlobalFilterSnapshot(); return Object.keys(raw) .sort() - .map((name, i) => { + .map((name) => { const seriesMeta = meta[name] ?? {}; const rawValues = Array.isArray(raw[name]) ? raw[name] : []; - // Stable color by label (see `assignStableColors`); `colorFor(i)` is only a - // fallback for a label the caller did not pre-register in `colors`. - const color = colors.get(name) ?? colorFor(i); + // Color by FORMAT (hue) + ENGINE (shade), identity-based so the `?n=all` + // reshape never reshuffles a series' color (see `colorForSeries`). + const color = colorForSeries(name, seriesMeta); // `data` starts null-padded; `rebuildVisibleAndUpdate` fills the current // visible window with raw or LTTB-kept values. With `spanGaps: true` the // line connects across nulls, so a series with partial coverage still @@ -415,7 +412,6 @@ class ChartController { payload: null, ui: { y: 'linear', scope: DEFAULT_VISIBLE }, overrides: {}, - seriesColors: new Map(), displayUnit: IDENTITY_UNIT, fullLoaded: false, initialFetchEntry: null, @@ -789,20 +785,6 @@ class ChartController { return state.fullFetchPending; } - /** - * Fold this payload's series labels into the chart's stable color map - * (first-seen order) and return the map for [`buildDatasets`]. Run on every - * (re)build so the `?n=all` upgrade keeps every already-seen series on its - * original color and only assigns fresh palette slots to newly surfaced series. - */ - private colorsFor(payload: NormalizedChartPayload): ReadonlyMap { - this.state.seriesColors = assignStableColors( - this.state.seriesColors, - Object.keys(payload.series ?? {}), - ); - return this.state.seriesColors; - } - /** * Construct the Chart.js instance when the payload is present and the * enclosing group (if any) is open. Loads Chart.js lazily on first need. @@ -855,8 +837,8 @@ class ChartController { // recomputed only when `replaceChartPayload` swaps in the wider window. state.displayUnit = pickDisplayUnit(payload.unit_kind, collectAllValues(payload)); - const labels = payload.commits.map(labelForCommit); - const datasets = buildDatasets(payload, this.colorsFor(payload)); + const labels = payload.commits.map(commitDateLabel); + const datasets = buildDatasets(payload); const range = visibleRange(labels.length, state.ui.scope); const legendPosition = window.matchMedia?.('(max-width: 768px)').matches ? 'top' : 'bottom'; @@ -918,6 +900,7 @@ class ChartController { y: { type: state.ui.y === 'log' ? 'logarithmic' : 'linear', beginAtZero: state.ui.y !== 'log', + grid: { color: GRID_COLOR }, // The axis title reflects the locked display unit; empty for // dimensionless kinds so a "1.2x speedup" chart does not get an // arbitrary label. @@ -929,6 +912,7 @@ class ChartController { x: { min: range.min, max: range.max, + grid: { color: GRID_COLOR }, title: { display: false }, // One tick per commit is unreadable on a 5000-commit history; // Chart.js picks a sensible subset. @@ -1122,8 +1106,8 @@ class ChartController { yAxis.title.display = state.displayUnit.axisLabel !== ''; yAxis.title.text = state.displayUnit.axisLabel; } - const newLabels = payload.commits.map(labelForCommit); - const newDatasets = buildDatasets(payload, this.colorsFor(payload)); + const newLabels = payload.commits.map(commitDateLabel); + const newDatasets = buildDatasets(payload); // Honour any explicit legend toggles the user had made already. for (const ds of newDatasets) { if (ds.label && state.overrides[ds.label]) { diff --git a/web/lib/chart-format.test.ts b/web/lib/chart-format.test.ts index 6163803..c60e67e 100644 --- a/web/lib/chart-format.test.ts +++ b/web/lib/chart-format.test.ts @@ -9,8 +9,11 @@ import { clampRangeWindow, collectAllValues, colorFor, + colorForSeries, + commitDateLabel, decimateSeries, escapeHtml, + FALLBACK_PALETTE, FETCH_TIMEOUT_MS, firstLine, formatDisplayValue, @@ -112,6 +115,51 @@ describe('small formatting helpers', () => { }); }); +describe('colorForSeries', () => { + it('reproduces the v2 palette exactly for known engine:format series', () => { + expect(colorForSeries('datafusion:vortex')).toBe('#19a508'); + expect(colorForSeries('duckdb:vortex')).toBe('#0e5e04'); + expect(colorForSeries('datafusion:parquet')).toBe('#ef7f1d'); + // Same format, different engine → a different shade (not the same color). + expect(colorForSeries('datafusion:vortex')).not.toBe(colorForSeries('duckdb:vortex')); + }); + + it('keeps a Vortex series green via the format hue when the exact combo is unmapped', () => { + const c = colorForSeries('newengine:vortex', { engine: 'newengine', format: 'vortex' }); + expect(c).toMatch(/^#[0-9a-f]{6}$/); + const r = Number.parseInt(c.slice(1, 3), 16); + const g = Number.parseInt(c.slice(3, 5), 16); + const b = Number.parseInt(c.slice(5, 7), 16); + // A shade of the vortex green base (#19a508): green channel dominates. + expect(g).toBeGreaterThan(r); + expect(g).toBeGreaterThan(b); + }); + + it('falls back to a stable hashed palette slot for an unknown format', () => { + const first = colorForSeries('mystery', { format: 'unknownfmt' }); + expect([...FALLBACK_PALETTE] as string[]).toContain(first); + // Deterministic: same input → same color across calls. + expect(colorForSeries('mystery', { format: 'unknownfmt' })).toBe(first); + }); +}); + +describe('commitDateLabel', () => { + it('formats a commit timestamp as MMM D, YYYY', () => { + expect(commitDateLabel(commit('abc'))).toBe('Jun 1, 2026'); + }); + + it('derives the date from the ISO prefix so it is timezone-stable', () => { + expect( + commitDateLabel({ sha: 'x', timestamp: '2026-01-09T23:30:00Z', message: '', url: '' }), + ).toBe('Jan 9, 2026'); + }); + + it('returns empty for virtual slots and malformed timestamps', () => { + expect(commitDateLabel(null)).toBe(''); + expect(commitDateLabel({ sha: 'x', timestamp: '', message: '', url: '' })).toBe(''); + }); +}); + describe('magnitudeReference', () => { it('takes the median of finite nonzero magnitudes', () => { expect(magnitudeReference([1, 3, 2])).toBe(2); @@ -137,10 +185,11 @@ describe('pickDisplayUnit', () => { }); expect(pickDisplayUnit('time_ns', [5e3])).toMatchObject({ multiplier: 1e-3, suffix: 'µs' }); expect(pickDisplayUnit('time_ns', [5e6])).toMatchObject({ multiplier: 1e-6, suffix: 'ms' }); + // ms is the ceiling: a slow (≥1s) benchmark stays in ms, never seconds. expect(pickDisplayUnit('time_ns', [5e9])).toMatchObject({ - multiplier: 1e-9, - suffix: 's', - axisLabel: 'Time (s)', + multiplier: 1e-6, + suffix: 'ms', + axisLabel: 'Time (ms)', }); }); @@ -176,7 +225,7 @@ describe('pickDisplayUnit', () => { describe('formatDisplayValue', () => { it('applies the locked multiplier, decimals, and suffix', () => { const unit = pickDisplayUnit('time_ns', [12e9]); - expect(formatDisplayValue(12e9, unit)).toBe('12.00 s'); + expect(formatDisplayValue(12e9, unit)).toBe('12000.00 ms'); }); it('collapses missing values to an em dash', () => { diff --git a/web/lib/chart-format.ts b/web/lib/chart-format.ts index 7ae4147..51c0f2c 100644 --- a/web/lib/chart-format.ts +++ b/web/lib/chart-format.ts @@ -119,6 +119,118 @@ export function assignStableColors( return next; } +// --------------------------------------------------------------------------- +// Series colors by FORMAT (hue) and ENGINE (shade), ported from the v2 frontend +// (`src/config.js` + `src/utils.js`). A series' FORMAT picks its color family +// (Vortex green, Parquet orange, ...) and its ENGINE darkens that to a distinct +// shade, so e.g. every Vortex series is some green regardless of engine. The +// exact map reproduces the v2 per-(engine, format) shades for the known series; +// an unknown format falls back to a stable hashed slot. This is identity-based +// (not index-based), so colors are inherently stable across the `?n=all` +// reshape -- `assignStableColors` is no longer needed for the chart. +// --------------------------------------------------------------------------- + +/** Exact per-series colors keyed by the `engine:format` (or v2 `format-storage`) + * series label; reproduces the v2 `SERIES_COLOR_MAP`. */ +const SERIES_COLOR_MAP: Record = { + 'vortex-nvme': '#19a508', + 'vortex-compact-nvme': '#15850a', + 'parquet-nvme': '#ef7f1d', + 'lance-nvme': '#3B82F6', + 'datafusion:arrow': '#7a27b1', + 'datafusion:in-memory-arrow': '#7a27b1', + 'datafusion:parquet': '#ef7f1d', + 'datafusion:vortex': '#19a508', + 'datafusion:vortex-compact': '#15850a', + 'datafusion:lance': '#2D936C', + 'duckdb:parquet': '#985113', + 'duckdb:vortex': '#0e5e04', + 'duckdb:vortex-compact': '#0b4a03', + 'duckdb:duckdb': '#87752e', + 'vortex:lance': '#FF8787', +}; + +/** Base hue per format, so a series whose exact `engine:format` is not mapped + * still lands on its format's color family (Vortex green, Parquet orange, ...). */ +const FORMAT_HUE: Record = { + vortex: '#19a508', + 'vortex-compact': '#15850a', + parquet: '#ef7f1d', + lance: '#3B82F6', + arrow: '#7a27b1', + 'in-memory-arrow': '#7a27b1', + duckdb: '#87752e', +}; + +/** Stable fallback palette for series with no known format (ported from v2). */ +export const FALLBACK_PALETTE = [ + '#5971FD', + '#CEE562', + '#EEB3E1', + '#FF8C42', + '#B8336A', + '#726DA8', + '#2D936C', + '#E9B44C', +] as const; + +/** v2's `simpleHash`: a small deterministic 32-bit string hash, so an unmapped + * series keeps the same fallback color across reloads. */ +function simpleHash(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +/** Multiply a `#rrggbb` color's channels by `factor`, clamped to a byte. */ +function scaleHex(hex: string, factor: number): string { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex); + if (!m) { + return hex; + } + const n = Number.parseInt(m[1], 16); + const channel = (shift: number): string => { + const v = Math.max(0, Math.min(255, Math.round(((n >> shift) & 0xff) * factor))); + return v.toString(16).padStart(2, '0'); + }; + return `#${channel(16)}${channel(8)}${channel(0)}`; +} + +/** Darken a format's base hue into a per-engine shade so two engines of the same + * format stay distinguishable. No engine leaves the base hue unchanged. */ +function shadeForEngine(baseHex: string, engine: string | undefined): string { + if (!engine) { + return baseHex; + } + // Deterministic multiplier in {1.0, 0.85, 0.7, 0.55}, keyed by the engine name. + const factor = 1 - (simpleHash(engine) % 4) * 0.15; + return scaleHex(baseHex, factor); +} + +/** + * The line color for a series: its FORMAT picks the hue and its ENGINE the + * shade. Known `engine:format` series reproduce the v2 palette exactly; an + * unknown `engine:format` whose FORMAT is known still lands on that format's + * hue (engine-shaded); a series with no known format falls back to a stable + * hashed palette slot. + */ +export function colorForSeries( + name: string, + meta?: { engine?: string; format?: string } | null, +): string { + const exact = SERIES_COLOR_MAP[name]; + if (exact) { + return exact; + } + const format = meta?.format; + if (format && FORMAT_HUE[format]) { + return shadeForEngine(FORMAT_HUE[format], meta?.engine ?? undefined); + } + return FALLBACK_PALETTE[simpleHash(name) % FALLBACK_PALETTE.length]; +} + /** First 7 characters of a commit SHA. */ export function shortSha(sha: unknown): string { return typeof sha === 'string' ? sha.slice(0, 7) : String(sha); @@ -183,6 +295,42 @@ export function labelForCommit(commit: CommitPoint | null | undefined): string { return commit && commit.sha ? shortSha(commit.sha) : ''; } +const MONTH_ABBR = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] as const; + +/** + * The x-axis label for one commit slot as a date, `MMM D, YYYY` (e.g. + * `Jun 19, 2026`), or `''` for a virtual (not yet loaded) slot. Derived from the + * ISO timestamp's `YYYY-MM-DD` prefix rather than `new Date(...)` so the label + * is timezone-stable (no off-by-one-day drift across runtimes), matching the v2 + * frontend's dated x-axis that a reviewer found more readable than raw SHAs. + */ +export function commitDateLabel(commit: CommitPoint | null | undefined): string { + const ts = commit?.timestamp; + if (typeof ts !== 'string' || ts.length < 10) { + return ''; + } + const [year, month, day] = ts.slice(0, 10).split('-'); + const monthIdx = Number.parseInt(month, 10) - 1; + const dayNum = Number.parseInt(day, 10); + if (!(monthIdx >= 0 && monthIdx < 12) || !Number.isFinite(dayNum)) { + return ''; + } + return `${MONTH_ABBR[monthIdx]} ${dayNum}, ${year}`; +} + /** * The chronologically preceding raw value for a tooltip row's delta, or `null` * when no earlier measurement exists. `commits[]` is sorted oldest-first by @@ -307,8 +455,10 @@ export function collectAllValues(payload: Pick | null): return out; } -/** Steps: ns to µs (1e3) to ms (1e6) to s (1e9), picked by the median's - * magnitude so the y-axis tick numbers fit in 1-4 digits. */ +/** Steps: ns to µs (1e3) to ms (1e6), picked by the median's magnitude so the + * y-axis tick numbers fit in 1-4 digits. `ms` is the ceiling: a time axis never + * promotes to seconds, so charts stay comparable in one unit (the v2 frontend's + * fixed-ms axis, which a reviewer found more readable than auto-seconds). */ function pickTimeUnit(ref: number | null): { multiplier: number; suffix: string; @@ -320,10 +470,7 @@ function pickTimeUnit(ref: number | null): { if (ref < 1e6) { return { multiplier: 1e-3, suffix: 'µs', decimals: 2 }; } - if (ref < 1e9) { - return { multiplier: 1e-6, suffix: 'ms', decimals: 2 }; - } - return { multiplier: 1e-9, suffix: 's', decimals: 2 }; + return { multiplier: 1e-6, suffix: 'ms', decimals: 2 }; } /** Binary multiples to match how DuckDB and on-disk file sizes are typically From 46f8adc7af93b37749da6df1ceab558ed088aacf Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Fri, 19 Jun 2026 22:36:45 -0400 Subject: [PATCH 02/10] chore: ignore .env* files Keep local environment files (.env.local, etc.) out of git so secrets and machine-specific config are never accidentally committed. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Connor Tsui --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5732a5c..d35e51d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist .scratch/ /target .vercel +.env* From b0f71d00c8a34b714253238d8fbf53afe53e8ecb Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Fri, 19 Jun 2026 22:46:20 -0400 Subject: [PATCH 03/10] ci(web): deploy a Vercel preview on PRs and comment the URL Add a pull_request trigger to web-deploy.yml (scoped to web/ and the workflow itself) that builds and deploys a Vercel PREVIEW for the PR's branch, then posts the preview URL back to the PR as a single sticky comment. Production deploys on develop pushes are unchanged; fork PRs are skipped since they have no access to VERCEL_TOKEN. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Connor Tsui --- .github/workflows/web-deploy.yml | 51 +++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml index 2ae5f28..e248e8a 100644 --- a/.github/workflows/web-deploy.yml +++ b/.github/workflows/web-deploy.yml @@ -10,17 +10,22 @@ # The Vercel project's Root Directory must be set to `web/` (Phase-4.2 console step); # the CLI runs from the repo root and uses the pulled project settings to build web/. # -# Trigger: production deploy on push to `develop` (this repo's main branch after the -# decouple merge); `workflow_dispatch` for manual prod/preview runs during setup. No -# pull_request/feature-branch trigger — the project + secrets are provisioned in 4.2 -# and a feature-branch preview flow is out of scope here. End-to-end deploy is -# validated in Phase 4.2 once creds exist; 4.1 only authors + actionlint-validates. +# Triggers: production deploy on push to `develop`; a PREVIEW deploy on every +# pull_request that touches `web/` (or this workflow), which posts the preview URL +# back to the PR as a sticky comment; and `workflow_dispatch` for manual prod/preview +# runs. Fork PRs are skipped (they have no access to VERCEL_TOKEN). The CLI builds on +# the runner and deploys --prebuilt, so this repo never races the monorepo's project. name: Web Deploy on: push: branches: [develop] + pull_request: + branches: [develop] + paths: + - 'web/**' + - '.github/workflows/web-deploy.yml' workflow_dispatch: inputs: environment: @@ -38,6 +43,7 @@ concurrency: permissions: contents: read + pull-requests: write env: VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} @@ -51,6 +57,11 @@ jobs: name: Build & deploy to Vercel runs-on: ubuntu-latest timeout-minutes: 20 + # Fork PRs have no access to VERCEL_TOKEN, and we do not auto-deploy untrusted + # code; same-repo PRs, pushes, and manual dispatches run normally. + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository steps: - name: Checkout uses: actions/checkout@v4 @@ -78,9 +89,9 @@ jobs: - name: Resolve target environment id: target - # push to develop => production; manual dispatch uses the input. - # Expression context is passed through env: rather than interpolated into the - # shell body, so a value can never be parsed as shell (injection-safe). + # push to develop => production; pull_request => preview; manual dispatch => + # the input. Expression context is passed through env: rather than interpolated + # into the shell body, so a value can never be parsed as shell (injection-safe). env: EVENT_NAME: ${{ github.event_name }} INPUT_ENV: ${{ inputs.environment }} @@ -88,6 +99,8 @@ jobs: set -Eeuo pipefail if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then echo "env=${INPUT_ENV}" >> "$GITHUB_OUTPUT" + elif [ "${EVENT_NAME}" = "pull_request" ]; then + echo "env=preview" >> "$GITHUB_OUTPUT" else echo "env=production" >> "$GITHUB_OUTPUT" fi @@ -105,10 +118,28 @@ jobs: fi - name: Deploy (prebuilt) + id: deploy run: | set -Eeuo pipefail if [ "${{ steps.target.outputs.env }}" = "production" ]; then - vercel deploy --prebuilt --prod --token="${{ secrets.VERCEL_TOKEN }}" + url="$(vercel deploy --prebuilt --prod --token="${{ secrets.VERCEL_TOKEN }}" | tail -n1)" else - vercel deploy --prebuilt --token="${{ secrets.VERCEL_TOKEN }}" + url="$(vercel deploy --prebuilt --token="${{ secrets.VERCEL_TOKEN }}" | tail -n1)" fi + echo "url=${url}" >> "$GITHUB_OUTPUT" + printf 'Deployed: %s\n' "$url" + + # On a pull_request, post the preview URL back to the PR as a single sticky + # comment (edited in place on each push) so the preview is one click from the PR. + - name: Comment preview URL on the PR + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + DEPLOY_URL: ${{ steps.deploy.outputs.url }} + COMMIT_SHA: ${{ github.sha }} + run: | + set -Eeuo pipefail + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" \ + --body "Vercel preview for \`${COMMIT_SHA}\`: ${DEPLOY_URL}" \ + --edit-last --create-if-none From 3f07ca70790792d439d5a5db87a2a3d61e49ca0a Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Fri, 19 Jun 2026 23:36:57 -0400 Subject: [PATCH 04/10] feat(web): importance-tiered, theme-aware series colors Replace the format-hue/engine-shade coloring with a visual hierarchy, all solid lines. The four hero series (Vortex + Parquet on datafusion/duckdb) get vivid, thicker lines; everything else (Lance, Arrow, vortex-compact, the DuckDB native format) is muted and thin, so the comparisons that matter pop and the rest recede. Vortex owns a vivid green -- a light/dark split per engine -- so it stands out against the black/white chart chrome; Parquet is blue/cyan a notch quieter. Colors are theme-aware: a per-mode palette resolved from the page data-theme (or system preference), and a MutationObserver + prefers-color-scheme listener recolor open charts when the theme toggles. Vortex's neutral-popping greens need this, since one color can't read on both backgrounds. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Connor Tsui --- web/components/Chart.tsx | 76 +++++++++++++-- web/lib/chart-format.test.ts | 66 +++++++------ web/lib/chart-format.ts | 181 ++++++++++++++++------------------- 3 files changed, 189 insertions(+), 134 deletions(-) diff --git a/web/components/Chart.tsx b/web/components/Chart.tsx index 3d7b340..d1d8a75 100644 --- a/web/components/Chart.tsx +++ b/web/components/Chart.tsx @@ -18,7 +18,6 @@ import { CHART_FETCH_N, clampRangeWindow, collectAllValues, - colorForSeries, commitDateLabel, decimateSeries, DEFAULT_VISIBLE, @@ -41,6 +40,7 @@ import { rangeTouchesUnloadedHistory, seriesPassesFilter, seriesPassesGroupFilter, + seriesStyle, shortDate, shortSha, throttle, @@ -49,6 +49,7 @@ import { ZOOM_THROTTLE_MS, type DisplayUnit, type NormalizedChartPayload, + type ThemeMode, } from '@/lib/chart-format'; import { loadChartJs } from '@/lib/chart-js'; import { @@ -175,6 +176,22 @@ interface CardCallbacks { * Chart.js default. */ const GRID_COLOR = 'rgba(148, 163, 184, 0.14)'; +/** The resolved light/dark theme for coloring: the explicit `data-theme` override + * if set, else the system `prefers-color-scheme`. Read fresh each (re)build and on + * a theme change so series colors track the page theme. */ +function currentMode(): ThemeMode { + if (typeof document !== 'undefined') { + const attr = document.documentElement.getAttribute('data-theme'); + if (attr === 'light' || attr === 'dark') { + return attr; + } + } + if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + return 'light'; +} + // --------------------------------------------------------------------------- // Crosshair plugin: draws a vertical line at the chart's active hover index. // An inline plugin is cheaper than chartjs-plugin-crosshair, which is overkill @@ -220,7 +237,7 @@ function benchDatasets(chart: ChartJs): BenchDataset[] { * visible range. `rawData` holds a reference to the original payload so the * tooltip can show raw values regardless of LTTB. */ -function buildDatasets(payload: NormalizedChartPayload): BenchDataset[] { +function buildDatasets(payload: NormalizedChartPayload, mode: ThemeMode): BenchDataset[] { const raw = payload.series ?? {}; const meta = payload.series_meta ?? {}; const n = payload.commits.length; @@ -230,9 +247,9 @@ function buildDatasets(payload: NormalizedChartPayload): BenchDataset[] { .map((name) => { const seriesMeta = meta[name] ?? {}; const rawValues = Array.isArray(raw[name]) ? raw[name] : []; - // Color by FORMAT (hue) + ENGINE (shade), identity-based so the `?n=all` - // reshape never reshuffles a series' color (see `colorForSeries`). - const color = colorForSeries(name, seriesMeta); + // Tier-based, theme-aware style: the hero Vortex/Parquet series get vivid, + // thicker lines; everything else is muted and thin (see `seriesStyle`). + const style = seriesStyle(name, seriesMeta, mode); // `data` starts null-padded; `rebuildVisibleAndUpdate` fills the current // visible window with raw or LTTB-kept values. With `spanGaps: true` the // line connects across nulls, so a series with partial coverage still @@ -242,9 +259,9 @@ function buildDatasets(payload: NormalizedChartPayload): BenchDataset[] { label: name, data, rawData: rawValues, - borderColor: color, - backgroundColor: `${color}20`, - borderWidth: 1.5, + borderColor: style.color, + backgroundColor: `${style.color}20`, + borderWidth: style.width, spanGaps: true, tension: 0, pointRadius: 2, @@ -837,8 +854,9 @@ class ChartController { // recomputed only when `replaceChartPayload` swaps in the wider window. state.displayUnit = pickDisplayUnit(payload.unit_kind, collectAllValues(payload)); + const mode = currentMode(); const labels = payload.commits.map(commitDateLabel); - const datasets = buildDatasets(payload); + const datasets = buildDatasets(payload, mode); const range = visibleRange(labels.length, state.ui.scope); const legendPosition = window.matchMedia?.('(max-width: 768px)').matches ? 'top' : 'bottom'; @@ -978,6 +996,7 @@ class ChartController { this.cb.setConstructed(true); state.rebuild = throttledRebuild; this.attachWheelPan(canvas, chart, throttledRebuild); + this.attachThemeRecolor(); this.syncSliderBounds(labels.length); // Initial render: populate the null data for the initial window, then // bind the strip so its first paint reflects the same range. @@ -996,6 +1015,43 @@ class ChartController { } } + /** + * Recolor this chart's series when the page theme changes (the `data-theme` + * toggle or the system `prefers-color-scheme`), so the per-mode palette tracks + * light/dark. Both listeners ride the controller's abort signal, so teardown + * disconnects them. + */ + private attachThemeRecolor(): void { + if (typeof MutationObserver === 'undefined') { + return; + } + const observer = new MutationObserver(() => this.recolorForTheme()); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }); + this.aborter.signal.addEventListener('abort', () => observer.disconnect(), { once: true }); + window + .matchMedia?.('(prefers-color-scheme: dark)') + .addEventListener?.('change', () => this.recolorForTheme(), { signal: this.aborter.signal }); + } + + /** Recompute every dataset's color + width for the current theme and repaint. */ + private recolorForTheme(): void { + const chart = this.state.chart; + if (!chart || this.state.disposed) { + return; + } + const mode = currentMode(); + for (const ds of benchDatasets(chart)) { + const style = seriesStyle(ds.label ?? '', ds.benchMeta, mode); + ds.borderColor = style.color; + ds.backgroundColor = `${style.color}20`; + ds.borderWidth = style.width; + } + chart.update('none'); + } + /** * The single source of truth for the rendered point count, ported verbatim: * build the per-commit max-across-series union over `[rangeMin, rangeMax]`, @@ -1107,7 +1163,7 @@ class ChartController { yAxis.title.text = state.displayUnit.axisLabel; } const newLabels = payload.commits.map(commitDateLabel); - const newDatasets = buildDatasets(payload); + const newDatasets = buildDatasets(payload, currentMode()); // Honour any explicit legend toggles the user had made already. for (const ds of newDatasets) { if (ds.label && state.overrides[ds.label]) { diff --git a/web/lib/chart-format.test.ts b/web/lib/chart-format.test.ts index c60e67e..c1aa94a 100644 --- a/web/lib/chart-format.test.ts +++ b/web/lib/chart-format.test.ts @@ -9,11 +9,9 @@ import { clampRangeWindow, collectAllValues, colorFor, - colorForSeries, commitDateLabel, decimateSeries, escapeHtml, - FALLBACK_PALETTE, FETCH_TIMEOUT_MS, firstLine, formatDisplayValue, @@ -36,6 +34,7 @@ import { seedActiveFromAllowlist, seriesPassesFilter, seriesPassesGroupFilter, + seriesStyle, shortDate, shortSha, singleSearchParam, @@ -115,31 +114,44 @@ describe('small formatting helpers', () => { }); }); -describe('colorForSeries', () => { - it('reproduces the v2 palette exactly for known engine:format series', () => { - expect(colorForSeries('datafusion:vortex')).toBe('#19a508'); - expect(colorForSeries('duckdb:vortex')).toBe('#0e5e04'); - expect(colorForSeries('datafusion:parquet')).toBe('#ef7f1d'); - // Same format, different engine → a different shade (not the same color). - expect(colorForSeries('datafusion:vortex')).not.toBe(colorForSeries('duckdb:vortex')); - }); - - it('keeps a Vortex series green via the format hue when the exact combo is unmapped', () => { - const c = colorForSeries('newengine:vortex', { engine: 'newengine', format: 'vortex' }); - expect(c).toMatch(/^#[0-9a-f]{6}$/); - const r = Number.parseInt(c.slice(1, 3), 16); - const g = Number.parseInt(c.slice(3, 5), 16); - const b = Number.parseInt(c.slice(5, 7), 16); - // A shade of the vortex green base (#19a508): green channel dominates. - expect(g).toBeGreaterThan(r); - expect(g).toBeGreaterThan(b); - }); - - it('falls back to a stable hashed palette slot for an unknown format', () => { - const first = colorForSeries('mystery', { format: 'unknownfmt' }); - expect([...FALLBACK_PALETTE] as string[]).toContain(first); - // Deterministic: same input → same color across calls. - expect(colorForSeries('mystery', { format: 'unknownfmt' })).toBe(first); +describe('seriesStyle', () => { + const tag = (engine: string, format: string) => ({ engine, format }); + + it('makes the hero Vortex series the loudest: vivid green, distinct per engine, thickest', () => { + const dfv = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex'), 'light'); + const dkv = seriesStyle('duckdb:vortex', tag('duckdb', 'vortex'), 'light'); + expect(dfv.color).toBe('#16a34a'); + expect(dkv.color).toBe('#166534'); + expect(dfv.color).not.toBe(dkv.color); + const muted = seriesStyle('duckdb:lance', tag('duckdb', 'lance'), 'light'); + expect(dfv.width).toBeGreaterThan(muted.width); + }); + + it('puts Parquet a notch under Vortex (blue, thinner than the hero)', () => { + const vortex = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex'), 'light'); + const parquet = seriesStyle('datafusion:parquet', tag('datafusion', 'parquet'), 'light'); + expect(parquet.color).toBe('#2563eb'); + expect(parquet.width).toBeLessThan(vortex.width); + }); + + it('mutes and thins everything outside the four hero series', () => { + const parquet = seriesStyle('datafusion:parquet', tag('datafusion', 'parquet'), 'light'); + const lance = seriesStyle('datafusion:lance', tag('datafusion', 'lance'), 'light'); + const compact = seriesStyle('duckdb:vortex-compact', tag('duckdb', 'vortex-compact'), 'light'); + expect(lance.width).toBeLessThan(parquet.width); + expect(compact.width).toBeLessThan(parquet.width); + // vortex-compact is NOT a hero: it never gets the vivid vortex green. + expect(compact.color).not.toBe('#16a34a'); + }); + + it('swaps the palette per theme so a series differs in light vs dark', () => { + const light = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex'), 'light'); + const dark = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex'), 'dark'); + expect(light.color).not.toBe(dark.color); + }); + + it('derives engine/format from the label when meta is absent', () => { + expect(seriesStyle('datafusion:vortex', undefined, 'light').color).toBe('#16a34a'); }); }); diff --git a/web/lib/chart-format.ts b/web/lib/chart-format.ts index 51c0f2c..ab68469 100644 --- a/web/lib/chart-format.ts +++ b/web/lib/chart-format.ts @@ -120,115 +120,102 @@ export function assignStableColors( } // --------------------------------------------------------------------------- -// Series colors by FORMAT (hue) and ENGINE (shade), ported from the v2 frontend -// (`src/config.js` + `src/utils.js`). A series' FORMAT picks its color family -// (Vortex green, Parquet orange, ...) and its ENGINE darkens that to a distinct -// shade, so e.g. every Vortex series is some green regardless of engine. The -// exact map reproduces the v2 per-(engine, format) shades for the known series; -// an unknown format falls back to a stable hashed slot. This is identity-based -// (not index-based), so colors are inherently stable across the `?n=all` -// reshape -- `assignStableColors` is no longer needed for the chart. +// Series styling by importance tier, theme-aware. Color carries the signal and +// lines stay solid; the engine is a light/dark split of the format's color. +// +// Tier 1 (loudest, thickest): the Vortex series on the real engines -- the hero +// comparison. Vortex owns a vivid GREEN so it pops off the black/white chart +// chrome (axes, gridlines, text). Tier 2: the Parquet series, a notch quieter +// (blue/cyan). Tier 3: everything else (vortex-compact, lance, arrow, the duckdb +// native format) is muted and thin so it recedes and never out-shouts the four +// hero series. Each color is per-mode so neutral chrome never camouflages a line +// (e.g. Parquet's blue lightens on dark); the active mode is resolved from the +// page theme at the call site (see `components/Chart.tsx`). // --------------------------------------------------------------------------- -/** Exact per-series colors keyed by the `engine:format` (or v2 `format-storage`) - * series label; reproduces the v2 `SERIES_COLOR_MAP`. */ -const SERIES_COLOR_MAP: Record = { - 'vortex-nvme': '#19a508', - 'vortex-compact-nvme': '#15850a', - 'parquet-nvme': '#ef7f1d', - 'lance-nvme': '#3B82F6', - 'datafusion:arrow': '#7a27b1', - 'datafusion:in-memory-arrow': '#7a27b1', - 'datafusion:parquet': '#ef7f1d', - 'datafusion:vortex': '#19a508', - 'datafusion:vortex-compact': '#15850a', - 'datafusion:lance': '#2D936C', - 'duckdb:parquet': '#985113', - 'duckdb:vortex': '#0e5e04', - 'duckdb:vortex-compact': '#0b4a03', - 'duckdb:duckdb': '#87752e', - 'vortex:lance': '#FF8787', +/** Light or dark page theme; selects the per-mode palette. */ +export type ThemeMode = 'light' | 'dark'; + +/** The resolved line style for one series: a color and a width (px). */ +export interface SeriesStyle { + color: string; + width: number; +} + +/** Line widths per importance tier, reinforcing the color hierarchy. */ +const HERO_WIDTH = 2.4; +const SECONDARY_WIDTH = 1.8; +const MUTED_WIDTH = 1.2; + +/** Hero formats and their per-engine, per-mode colors. Vortex green is the + * loudest tier; Parquet blue/cyan is the secondary tier. A series whose format + * is not here, or whose engine is neither datafusion nor duckdb, is muted. */ +const HERO: Record< + ThemeMode, + Record +> = { + light: { + vortex: { datafusion: '#16a34a', duckdb: '#166534', width: HERO_WIDTH }, + parquet: { datafusion: '#2563eb', duckdb: '#0e7490', width: SECONDARY_WIDTH }, + }, + dark: { + vortex: { datafusion: '#4ade80', duckdb: '#15803d', width: HERO_WIDTH }, + parquet: { datafusion: '#60a5fa', duckdb: '#22d3ee', width: SECONDARY_WIDTH }, + }, }; -/** Base hue per format, so a series whose exact `engine:format` is not mapped - * still lands on its format's color family (Vortex green, Parquet orange, ...). */ -const FORMAT_HUE: Record = { - vortex: '#19a508', - 'vortex-compact': '#15850a', - parquet: '#ef7f1d', - lance: '#3B82F6', - arrow: '#7a27b1', - 'in-memory-arrow': '#7a27b1', - duckdb: '#87752e', +/** Muted, desaturated colors for the non-hero formats, per mode -- distinct + * enough to tell apart, quiet enough to recede behind the hero series. */ +const MUTED: Record> = { + light: { + 'vortex-compact': '#7c9a86', + lance: '#9c93b0', + arrow: '#b79a93', + 'in-memory-arrow': '#b79a93', + duckdb: '#b3a875', + }, + dark: { + 'vortex-compact': '#5d7567', + lance: '#6f667e', + arrow: '#7d6760', + 'in-memory-arrow': '#7d6760', + duckdb: '#7a7050', + }, }; -/** Stable fallback palette for series with no known format (ported from v2). */ -export const FALLBACK_PALETTE = [ - '#5971FD', - '#CEE562', - '#EEB3E1', - '#FF8C42', - '#B8336A', - '#726DA8', - '#2D936C', - '#E9B44C', -] as const; - -/** v2's `simpleHash`: a small deterministic 32-bit string hash, so an unmapped - * series keeps the same fallback color across reloads. */ -function simpleHash(str: string): number { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; - } - return Math.abs(hash); -} +/** The catch-all muted color for an unknown format, per mode. */ +const MUTED_FALLBACK: Record = { light: '#9aa1a9', dark: '#5f6671' }; -/** Multiply a `#rrggbb` color's channels by `factor`, clamped to a byte. */ -function scaleHex(hex: string, factor: number): string { - const m = /^#?([0-9a-fA-F]{6})$/.exec(hex); - if (!m) { - return hex; - } - const n = Number.parseInt(m[1], 16); - const channel = (shift: number): string => { - const v = Math.max(0, Math.min(255, Math.round(((n >> shift) & 0xff) * factor))); - return v.toString(16).padStart(2, '0'); - }; - return `#${channel(16)}${channel(8)}${channel(0)}`; -} - -/** Darken a format's base hue into a per-engine shade so two engines of the same - * format stay distinguishable. No engine leaves the base hue unchanged. */ -function shadeForEngine(baseHex: string, engine: string | undefined): string { - if (!engine) { - return baseHex; - } - // Deterministic multiplier in {1.0, 0.85, 0.7, 0.55}, keyed by the engine name. - const factor = 1 - (simpleHash(engine) % 4) * 0.15; - return scaleHex(baseHex, factor); +/** Resolve a series' `(engine, format)` from its meta, falling back to splitting + * the `engine:format` label. */ +function seriesDims( + name: string, + meta: { engine?: string; format?: string } | null | undefined, +): { engine: string | undefined; format: string | undefined } { + const colon = name.indexOf(':'); + const engine = meta?.engine ?? (colon >= 0 ? name.slice(0, colon) : undefined); + const format = meta?.format ?? (colon >= 0 ? name.slice(colon + 1) : name); + return { engine, format }; } /** - * The line color for a series: its FORMAT picks the hue and its ENGINE the - * shade. Known `engine:format` series reproduce the v2 palette exactly; an - * unknown `engine:format` whose FORMAT is known still lands on that format's - * hue (engine-shaded); a series with no known format falls back to a stable - * hashed palette slot. + * The line color and width for a series in the given theme `mode`. The four hero + * series (Vortex / Parquet on datafusion + duckdb) get vivid, thicker lines; + * everything else is muted and thin. Color carries the signal, so lines stay + * solid and the hierarchy reads at a glance. */ -export function colorForSeries( +export function seriesStyle( name: string, - meta?: { engine?: string; format?: string } | null, -): string { - const exact = SERIES_COLOR_MAP[name]; - if (exact) { - return exact; - } - const format = meta?.format; - if (format && FORMAT_HUE[format]) { - return shadeForEngine(FORMAT_HUE[format], meta?.engine ?? undefined); - } - return FALLBACK_PALETTE[simpleHash(name) % FALLBACK_PALETTE.length]; + meta: { engine?: string; format?: string } | null | undefined, + mode: ThemeMode, +): SeriesStyle { + const { engine, format } = seriesDims(name, meta); + const hero = format ? HERO[mode][format] : undefined; + if (hero && (engine === 'datafusion' || engine === 'duckdb')) { + return { color: engine === 'duckdb' ? hero.duckdb : hero.datafusion, width: hero.width }; + } + const color = (format && MUTED[mode][format]) || MUTED_FALLBACK[mode]; + return { color, width: MUTED_WIDTH }; } /** First 7 characters of a commit SHA. */ From fce18c288ffd59d32b25512644589ea00d259acc Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Sat, 20 Jun 2026 00:10:55 -0400 Subject: [PATCH 05/10] feat(web): hand-tuned per-series palette, mode-independent Replace the per-mode tier palette with an explicit color per engine:format chosen to read on both the light and dark card backgrounds. Vortex is the loud hero (bright red on datafusion, neon green on duckdb), Parquet the secondary (light/dark blue), vortex-compact its own purple, the duckdb native format gold, and the rest muted/thin (lance neutral slate, datafusion:arrow orange). Because the palette no longer changes with the theme, drop the chart's theme-recolor path entirely: the MutationObserver on data-theme, the prefers-color-scheme listener, currentMode(), and recolorForTheme(). seriesStyle loses its mode parameter. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Connor Tsui --- web/components/Chart.tsx | 68 ++-------------------- web/lib/chart-format.test.ts | 50 +++++++++-------- web/lib/chart-format.ts | 106 +++++++++++++++-------------------- 3 files changed, 80 insertions(+), 144 deletions(-) diff --git a/web/components/Chart.tsx b/web/components/Chart.tsx index d1d8a75..13925c6 100644 --- a/web/components/Chart.tsx +++ b/web/components/Chart.tsx @@ -49,7 +49,6 @@ import { ZOOM_THROTTLE_MS, type DisplayUnit, type NormalizedChartPayload, - type ThemeMode, } from '@/lib/chart-format'; import { loadChartJs } from '@/lib/chart-js'; import { @@ -176,22 +175,6 @@ interface CardCallbacks { * Chart.js default. */ const GRID_COLOR = 'rgba(148, 163, 184, 0.14)'; -/** The resolved light/dark theme for coloring: the explicit `data-theme` override - * if set, else the system `prefers-color-scheme`. Read fresh each (re)build and on - * a theme change so series colors track the page theme. */ -function currentMode(): ThemeMode { - if (typeof document !== 'undefined') { - const attr = document.documentElement.getAttribute('data-theme'); - if (attr === 'light' || attr === 'dark') { - return attr; - } - } - if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) { - return 'dark'; - } - return 'light'; -} - // --------------------------------------------------------------------------- // Crosshair plugin: draws a vertical line at the chart's active hover index. // An inline plugin is cheaper than chartjs-plugin-crosshair, which is overkill @@ -237,7 +220,7 @@ function benchDatasets(chart: ChartJs): BenchDataset[] { * visible range. `rawData` holds a reference to the original payload so the * tooltip can show raw values regardless of LTTB. */ -function buildDatasets(payload: NormalizedChartPayload, mode: ThemeMode): BenchDataset[] { +function buildDatasets(payload: NormalizedChartPayload): BenchDataset[] { const raw = payload.series ?? {}; const meta = payload.series_meta ?? {}; const n = payload.commits.length; @@ -247,9 +230,9 @@ function buildDatasets(payload: NormalizedChartPayload, mode: ThemeMode): BenchD .map((name) => { const seriesMeta = meta[name] ?? {}; const rawValues = Array.isArray(raw[name]) ? raw[name] : []; - // Tier-based, theme-aware style: the hero Vortex/Parquet series get vivid, - // thicker lines; everything else is muted and thin (see `seriesStyle`). - const style = seriesStyle(name, seriesMeta, mode); + // Tier-based style: the named Vortex/Parquet series get vivid, thicker + // lines; everything else is muted and thin (see `seriesStyle`). + const style = seriesStyle(name, seriesMeta); // `data` starts null-padded; `rebuildVisibleAndUpdate` fills the current // visible window with raw or LTTB-kept values. With `spanGaps: true` the // line connects across nulls, so a series with partial coverage still @@ -854,9 +837,8 @@ class ChartController { // recomputed only when `replaceChartPayload` swaps in the wider window. state.displayUnit = pickDisplayUnit(payload.unit_kind, collectAllValues(payload)); - const mode = currentMode(); const labels = payload.commits.map(commitDateLabel); - const datasets = buildDatasets(payload, mode); + const datasets = buildDatasets(payload); const range = visibleRange(labels.length, state.ui.scope); const legendPosition = window.matchMedia?.('(max-width: 768px)').matches ? 'top' : 'bottom'; @@ -996,7 +978,6 @@ class ChartController { this.cb.setConstructed(true); state.rebuild = throttledRebuild; this.attachWheelPan(canvas, chart, throttledRebuild); - this.attachThemeRecolor(); this.syncSliderBounds(labels.length); // Initial render: populate the null data for the initial window, then // bind the strip so its first paint reflects the same range. @@ -1015,43 +996,6 @@ class ChartController { } } - /** - * Recolor this chart's series when the page theme changes (the `data-theme` - * toggle or the system `prefers-color-scheme`), so the per-mode palette tracks - * light/dark. Both listeners ride the controller's abort signal, so teardown - * disconnects them. - */ - private attachThemeRecolor(): void { - if (typeof MutationObserver === 'undefined') { - return; - } - const observer = new MutationObserver(() => this.recolorForTheme()); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-theme'], - }); - this.aborter.signal.addEventListener('abort', () => observer.disconnect(), { once: true }); - window - .matchMedia?.('(prefers-color-scheme: dark)') - .addEventListener?.('change', () => this.recolorForTheme(), { signal: this.aborter.signal }); - } - - /** Recompute every dataset's color + width for the current theme and repaint. */ - private recolorForTheme(): void { - const chart = this.state.chart; - if (!chart || this.state.disposed) { - return; - } - const mode = currentMode(); - for (const ds of benchDatasets(chart)) { - const style = seriesStyle(ds.label ?? '', ds.benchMeta, mode); - ds.borderColor = style.color; - ds.backgroundColor = `${style.color}20`; - ds.borderWidth = style.width; - } - chart.update('none'); - } - /** * The single source of truth for the rendered point count, ported verbatim: * build the per-commit max-across-series union over `[rangeMin, rangeMax]`, @@ -1163,7 +1107,7 @@ class ChartController { yAxis.title.text = state.displayUnit.axisLabel; } const newLabels = payload.commits.map(commitDateLabel); - const newDatasets = buildDatasets(payload, currentMode()); + const newDatasets = buildDatasets(payload); // Honour any explicit legend toggles the user had made already. for (const ds of newDatasets) { if (ds.label && state.overrides[ds.label]) { diff --git a/web/lib/chart-format.test.ts b/web/lib/chart-format.test.ts index c1aa94a..4787a87 100644 --- a/web/lib/chart-format.test.ts +++ b/web/lib/chart-format.test.ts @@ -117,41 +117,47 @@ describe('small formatting helpers', () => { describe('seriesStyle', () => { const tag = (engine: string, format: string) => ({ engine, format }); - it('makes the hero Vortex series the loudest: vivid green, distinct per engine, thickest', () => { - const dfv = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex'), 'light'); - const dkv = seriesStyle('duckdb:vortex', tag('duckdb', 'vortex'), 'light'); - expect(dfv.color).toBe('#16a34a'); - expect(dkv.color).toBe('#166534'); + it('makes the hero Vortex series the loudest: red/green, distinct per engine, thickest', () => { + const dfv = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex')); + const dkv = seriesStyle('duckdb:vortex', tag('duckdb', 'vortex')); + expect(dfv.color).toBe('#ef4444'); + expect(dkv.color).toBe('#22c55e'); expect(dfv.color).not.toBe(dkv.color); - const muted = seriesStyle('duckdb:lance', tag('duckdb', 'lance'), 'light'); + const muted = seriesStyle('duckdb:lance', tag('duckdb', 'lance')); expect(dfv.width).toBeGreaterThan(muted.width); }); it('puts Parquet a notch under Vortex (blue, thinner than the hero)', () => { - const vortex = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex'), 'light'); - const parquet = seriesStyle('datafusion:parquet', tag('datafusion', 'parquet'), 'light'); - expect(parquet.color).toBe('#2563eb'); + const vortex = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex')); + const parquet = seriesStyle('datafusion:parquet', tag('datafusion', 'parquet')); + expect(parquet.color).toBe('#38bdf8'); expect(parquet.width).toBeLessThan(vortex.width); }); - it('mutes and thins everything outside the four hero series', () => { - const parquet = seriesStyle('datafusion:parquet', tag('datafusion', 'parquet'), 'light'); - const lance = seriesStyle('datafusion:lance', tag('datafusion', 'lance'), 'light'); - const compact = seriesStyle('duckdb:vortex-compact', tag('duckdb', 'vortex-compact'), 'light'); - expect(lance.width).toBeLessThan(parquet.width); - expect(compact.width).toBeLessThan(parquet.width); - // vortex-compact is NOT a hero: it never gets the vivid vortex green. - expect(compact.color).not.toBe('#16a34a'); + it('distinguishes the two lance engines but keeps them thinner than Parquet', () => { + const parquet = seriesStyle('datafusion:parquet', tag('datafusion', 'parquet')); + const dfLance = seriesStyle('datafusion:lance', tag('datafusion', 'lance')); + const dkLance = seriesStyle('duckdb:lance', tag('duckdb', 'lance')); + expect(dfLance.width).toBeLessThan(parquet.width); + expect(dfLance.color).not.toBe(dkLance.color); }); - it('swaps the palette per theme so a series differs in light vs dark', () => { - const light = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex'), 'light'); - const dark = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex'), 'dark'); - expect(light.color).not.toBe(dark.color); + it('gives vortex-compact its own purple, never the vortex red/green', () => { + const compact = seriesStyle('datafusion:vortex-compact', tag('datafusion', 'vortex-compact')); + expect(compact.color).toBe('#a855f7'); + expect(compact.color).not.toBe('#ef4444'); + expect(compact.color).not.toBe('#22c55e'); }); it('derives engine/format from the label when meta is absent', () => { - expect(seriesStyle('datafusion:vortex', undefined, 'light').color).toBe('#16a34a'); + expect(seriesStyle('datafusion:vortex', undefined).color).toBe('#ef4444'); + }); + + it('falls back to a muted color + width for an unknown series', () => { + const unknown = seriesStyle('datafusion:mystery', tag('datafusion', 'mystery')); + expect(unknown.color).toBe('#94a3b8'); + const hero = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex')); + expect(unknown.width).toBeLessThan(hero.width); }); }); diff --git a/web/lib/chart-format.ts b/web/lib/chart-format.ts index ab68469..d00e42b 100644 --- a/web/lib/chart-format.ts +++ b/web/lib/chart-format.ts @@ -120,22 +120,20 @@ export function assignStableColors( } // --------------------------------------------------------------------------- -// Series styling by importance tier, theme-aware. Color carries the signal and -// lines stay solid; the engine is a light/dark split of the format's color. +// Series styling by importance, hand-tuned per `engine:format`. Color carries +// the signal and lines stay solid. The palette is mode-independent: every color +// is chosen to read on both the light (white) and dark card backgrounds, so the +// chart never recolors when the page theme toggles. // -// Tier 1 (loudest, thickest): the Vortex series on the real engines -- the hero -// comparison. Vortex owns a vivid GREEN so it pops off the black/white chart -// chrome (axes, gridlines, text). Tier 2: the Parquet series, a notch quieter -// (blue/cyan). Tier 3: everything else (vortex-compact, lance, arrow, the duckdb -// native format) is muted and thin so it recedes and never out-shouts the four -// hero series. Each color is per-mode so neutral chrome never camouflages a line -// (e.g. Parquet's blue lightens on dark); the active mode is resolved from the -// page theme at the call site (see `components/Chart.tsx`). +// The named series are the comparison that matters and get the loud, thick +// lines: Vortex (bright red on datafusion, neon green on duckdb) and Parquet +// (light / dark blue). A notch quieter: vortex-compact (purple) and the duckdb +// native format (gold). Everything else is muted and thin so it recedes and +// never out-shouts the comparison -- lance is neutral slate (it is the least +// important and is hidden by default soon), and `datafusion:arrow` keeps an +// orange where it is still worth reading. // --------------------------------------------------------------------------- -/** Light or dark page theme; selects the per-mode palette. */ -export type ThemeMode = 'light' | 'dark'; - /** The resolved line style for one series: a color and a width (px). */ export interface SeriesStyle { color: string; @@ -143,48 +141,36 @@ export interface SeriesStyle { } /** Line widths per importance tier, reinforcing the color hierarchy. */ -const HERO_WIDTH = 2.4; -const SECONDARY_WIDTH = 1.8; -const MUTED_WIDTH = 1.2; - -/** Hero formats and their per-engine, per-mode colors. Vortex green is the - * loudest tier; Parquet blue/cyan is the secondary tier. A series whose format - * is not here, or whose engine is neither datafusion nor duckdb, is muted. */ -const HERO: Record< - ThemeMode, - Record -> = { - light: { - vortex: { datafusion: '#16a34a', duckdb: '#166534', width: HERO_WIDTH }, - parquet: { datafusion: '#2563eb', duckdb: '#0e7490', width: SECONDARY_WIDTH }, - }, - dark: { - vortex: { datafusion: '#4ade80', duckdb: '#15803d', width: HERO_WIDTH }, - parquet: { datafusion: '#60a5fa', duckdb: '#22d3ee', width: SECONDARY_WIDTH }, - }, +const HERO_WIDTH = 2.6; +const COMPACT_WIDTH = 1.9; +const SECONDARY_WIDTH = 2.0; +const NATIVE_WIDTH = 1.7; +const MUTED_WIDTH = 1.4; + +/** The named series, keyed by `engine:format`, each with a hand-picked color and + * an importance-tier width. A series not listed here falls through to `MUTED`. */ +const KEYED: Record = { + 'datafusion:vortex': { color: '#ef4444', width: HERO_WIDTH }, // bright red + 'duckdb:vortex': { color: '#22c55e', width: HERO_WIDTH }, // neon green + 'datafusion:vortex-compact': { color: '#a855f7', width: COMPACT_WIDTH }, // bright purple + 'duckdb:vortex-compact': { color: '#7e22ce', width: COMPACT_WIDTH }, // deep purple + 'datafusion:parquet': { color: '#38bdf8', width: SECONDARY_WIDTH }, // light blue + 'duckdb:parquet': { color: '#2563eb', width: SECONDARY_WIDTH }, // dark(er) blue + 'duckdb:duckdb': { color: '#eab308', width: NATIVE_WIDTH }, // gold }; -/** Muted, desaturated colors for the non-hero formats, per mode -- distinct - * enough to tell apart, quiet enough to recede behind the hero series. */ -const MUTED: Record> = { - light: { - 'vortex-compact': '#7c9a86', - lance: '#9c93b0', - arrow: '#b79a93', - 'in-memory-arrow': '#b79a93', - duckdb: '#b3a875', - }, - dark: { - 'vortex-compact': '#5d7567', - lance: '#6f667e', - arrow: '#7d6760', - 'in-memory-arrow': '#7d6760', - duckdb: '#7a7050', - }, +/** Muted colors for the non-named series, looked up by `engine:format` first + * (so the two lance engines stay distinguishable) then by bare format. */ +const MUTED: Record = { + 'datafusion:lance': '#94a3b8', // neutral slate + 'duckdb:lance': '#475569', // darker slate + 'datafusion:arrow': '#f97316', // orange + 'in-memory-arrow': '#f97316', // orange + arrow: '#a8a29e', // warm stone gray (other engines) }; -/** The catch-all muted color for an unknown format, per mode. */ -const MUTED_FALLBACK: Record = { light: '#9aa1a9', dark: '#5f6671' }; +/** The catch-all muted color for an otherwise-unknown series. */ +const MUTED_FALLBACK = '#94a3b8'; /** Resolve a series' `(engine, format)` from its meta, falling back to splitting * the `engine:format` label. */ @@ -199,22 +185,22 @@ function seriesDims( } /** - * The line color and width for a series in the given theme `mode`. The four hero - * series (Vortex / Parquet on datafusion + duckdb) get vivid, thicker lines; - * everything else is muted and thin. Color carries the signal, so lines stay - * solid and the hierarchy reads at a glance. + * The line color and width for a series. The named Vortex / Parquet series get + * vivid, thicker lines; everything else is muted and thin. Color carries the + * signal, so lines stay solid and the hierarchy reads at a glance. The palette + * is mode-independent -- each color reads on both the light and dark card. */ export function seriesStyle( name: string, meta: { engine?: string; format?: string } | null | undefined, - mode: ThemeMode, ): SeriesStyle { const { engine, format } = seriesDims(name, meta); - const hero = format ? HERO[mode][format] : undefined; - if (hero && (engine === 'datafusion' || engine === 'duckdb')) { - return { color: engine === 'duckdb' ? hero.duckdb : hero.datafusion, width: hero.width }; + const key = engine && format ? `${engine}:${format}` : name; + const keyed = KEYED[key]; + if (keyed) { + return keyed; } - const color = (format && MUTED[mode][format]) || MUTED_FALLBACK[mode]; + const color = MUTED[key] || (format ? MUTED[format] : undefined) || MUTED_FALLBACK; return { color, width: MUTED_WIDTH }; } From 64674a2342c281e345fdb8e16457a3d10b4340ce Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Sat, 20 Jun 2026 00:18:17 -0400 Subject: [PATCH 06/10] ci(web): native PR deployment status alongside the preview comment Vercel's git integration is off on this project, so the CLI deploy never produced GitHub's native "View deployment" button or an in-progress status. Post them from the workflow itself: open a Deployment against the PR head before the build (shows the in-progress spinner immediately), then flip it to success with the preview URL (the button) or to failure. The sticky preview comment stays as a second, inline surface. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Connor Tsui --- .github/workflows/web-deploy.yml | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml index e248e8a..810b8e2 100644 --- a/.github/workflows/web-deploy.yml +++ b/.github/workflows/web-deploy.yml @@ -44,6 +44,10 @@ concurrency: permissions: contents: read pull-requests: write + # Create the GitHub Deployment + statuses that render the native "View + # deployment" button and the in-progress spinner on the PR (Vercel's git + # integration is off here, so the workflow posts these itself). + deployments: write env: VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} @@ -66,6 +70,39 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Open a GitHub Deployment against the PR head commit BEFORE the build, so + # the PR shows an in-progress (spinner) status immediately and the later + # success/failure step flips it to the "View deployment" button. PR-only; + # `transient_environment` lets GitHub retire superseded previews. A failure + # to open the deployment must not fail the build, so the `id` is optional + # and every downstream step guards on it being non-empty. + - name: Start preview deployment + id: deployment + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -Eeuo pipefail + id="$(gh api "repos/${GITHUB_REPOSITORY}/deployments" --method POST --jq '.id' --input - <> "$GITHUB_OUTPUT" + if [ -n "${id}" ]; then + gh api "repos/${GITHUB_REPOSITORY}/deployments/${id}/statuses" --method POST --input - >/dev/null </dev/null </dev/null < Date: Sat, 20 Jun 2026 00:26:29 -0400 Subject: [PATCH 07/10] fix(web): key the palette on real format names, color compression series The palette keyed Vortex on the format string `vortex`, but the data calls it `vortex-file-compressed`, so every series missed and fell to the gray fallback. Key the hero on the real name, and add per-format defaults so the engine-less compression-time series get their format's signature color instead of gray. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Connor Tsui --- web/lib/chart-format.test.ts | 17 ++++++++---- web/lib/chart-format.ts | 52 +++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/web/lib/chart-format.test.ts b/web/lib/chart-format.test.ts index 4787a87..d9d764a 100644 --- a/web/lib/chart-format.test.ts +++ b/web/lib/chart-format.test.ts @@ -118,8 +118,8 @@ describe('seriesStyle', () => { const tag = (engine: string, format: string) => ({ engine, format }); it('makes the hero Vortex series the loudest: red/green, distinct per engine, thickest', () => { - const dfv = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex')); - const dkv = seriesStyle('duckdb:vortex', tag('duckdb', 'vortex')); + const dfv = seriesStyle('datafusion:vortex-file-compressed', tag('datafusion', 'vortex-file-compressed')); + const dkv = seriesStyle('duckdb:vortex-file-compressed', tag('duckdb', 'vortex-file-compressed')); expect(dfv.color).toBe('#ef4444'); expect(dkv.color).toBe('#22c55e'); expect(dfv.color).not.toBe(dkv.color); @@ -128,7 +128,7 @@ describe('seriesStyle', () => { }); it('puts Parquet a notch under Vortex (blue, thinner than the hero)', () => { - const vortex = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex')); + const vortex = seriesStyle('datafusion:vortex-file-compressed', tag('datafusion', 'vortex-file-compressed')); const parquet = seriesStyle('datafusion:parquet', tag('datafusion', 'parquet')); expect(parquet.color).toBe('#38bdf8'); expect(parquet.width).toBeLessThan(vortex.width); @@ -150,13 +150,20 @@ describe('seriesStyle', () => { }); it('derives engine/format from the label when meta is absent', () => { - expect(seriesStyle('datafusion:vortex', undefined).color).toBe('#ef4444'); + expect(seriesStyle('datafusion:vortex-file-compressed', undefined).color).toBe('#ef4444'); + }); + + it('colors the engine-less compression-time series by its format', () => { + // Compression-time series carry a format but no engine; they pick up the + // format's signature color (here Vortex red) rather than the gray fallback. + const comp = seriesStyle('vortex-file-compressed:encode', { format: 'vortex-file-compressed' }); + expect(comp.color).toBe('#ef4444'); }); it('falls back to a muted color + width for an unknown series', () => { const unknown = seriesStyle('datafusion:mystery', tag('datafusion', 'mystery')); expect(unknown.color).toBe('#94a3b8'); - const hero = seriesStyle('datafusion:vortex', tag('datafusion', 'vortex')); + const hero = seriesStyle('datafusion:vortex-file-compressed', tag('datafusion', 'vortex-file-compressed')); expect(unknown.width).toBeLessThan(hero.width); }); }); diff --git a/web/lib/chart-format.ts b/web/lib/chart-format.ts index d00e42b..7c48fcd 100644 --- a/web/lib/chart-format.ts +++ b/web/lib/chart-format.ts @@ -147,30 +147,38 @@ const SECONDARY_WIDTH = 2.0; const NATIVE_WIDTH = 1.7; const MUTED_WIDTH = 1.4; -/** The named series, keyed by `engine:format`, each with a hand-picked color and - * an importance-tier width. A series not listed here falls through to `MUTED`. */ +/** The query series, keyed by `engine:format`, each with a hand-picked color and + * an importance-tier width. The Vortex hero is the on-disk `vortex-file-compressed` + * format -- red on datafusion, green on duckdb. The two lance engines carry + * distinct slate shades so they stay apart. A pair not listed here falls through + * to the per-format defaults in `FORMAT`. */ const KEYED: Record = { - 'datafusion:vortex': { color: '#ef4444', width: HERO_WIDTH }, // bright red - 'duckdb:vortex': { color: '#22c55e', width: HERO_WIDTH }, // neon green + 'datafusion:vortex-file-compressed': { color: '#ef4444', width: HERO_WIDTH }, // bright red + 'duckdb:vortex-file-compressed': { color: '#22c55e', width: HERO_WIDTH }, // neon green 'datafusion:vortex-compact': { color: '#a855f7', width: COMPACT_WIDTH }, // bright purple 'duckdb:vortex-compact': { color: '#7e22ce', width: COMPACT_WIDTH }, // deep purple 'datafusion:parquet': { color: '#38bdf8', width: SECONDARY_WIDTH }, // light blue 'duckdb:parquet': { color: '#2563eb', width: SECONDARY_WIDTH }, // dark(er) blue 'duckdb:duckdb': { color: '#eab308', width: NATIVE_WIDTH }, // gold + 'datafusion:lance': { color: '#94a3b8', width: MUTED_WIDTH }, // neutral slate + 'duckdb:lance': { color: '#475569', width: MUTED_WIDTH }, // darker slate + 'datafusion:arrow': { color: '#f97316', width: MUTED_WIDTH }, // orange }; -/** Muted colors for the non-named series, looked up by `engine:format` first - * (so the two lance engines stay distinguishable) then by bare format. */ -const MUTED: Record = { - 'datafusion:lance': '#94a3b8', // neutral slate - 'duckdb:lance': '#475569', // darker slate - 'datafusion:arrow': '#f97316', // orange - 'in-memory-arrow': '#f97316', // orange - arrow: '#a8a29e', // warm stone gray (other engines) +/** Per-format defaults, used for the engine-less compression-time series and as + * the fallback for any `engine:format` pair not pinned in `KEYED`. Each format's + * signature color matches its query-series color (Vortex's datafusion red). */ +const FORMAT: Record = { + 'vortex-file-compressed': { color: '#ef4444', width: HERO_WIDTH }, // red + 'vortex-compact': { color: '#a855f7', width: COMPACT_WIDTH }, // purple + parquet: { color: '#38bdf8', width: SECONDARY_WIDTH }, // light blue + duckdb: { color: '#eab308', width: NATIVE_WIDTH }, // gold + lance: { color: '#94a3b8', width: MUTED_WIDTH }, // slate + arrow: { color: '#f97316', width: MUTED_WIDTH }, // orange }; -/** The catch-all muted color for an otherwise-unknown series. */ -const MUTED_FALLBACK = '#94a3b8'; +/** The catch-all style for an otherwise-unknown series. */ +const FALLBACK: SeriesStyle = { color: '#94a3b8', width: MUTED_WIDTH }; /** Resolve a series' `(engine, format)` from its meta, falling back to splitting * the `engine:format` label. */ @@ -185,23 +193,23 @@ function seriesDims( } /** - * The line color and width for a series. The named Vortex / Parquet series get - * vivid, thicker lines; everything else is muted and thin. Color carries the - * signal, so lines stay solid and the hierarchy reads at a glance. The palette - * is mode-independent -- each color reads on both the light and dark card. + * The line color and width for a series. The named Vortex / Parquet query series + * get vivid, thicker lines; everything else is muted and thin. An `engine:format` + * pin wins first; otherwise the format's signature default applies (which also + * colors the engine-less compression-time series). Color carries the signal, so + * lines stay solid and the hierarchy reads at a glance. The palette is + * mode-independent -- each color reads on both the light and dark card. */ export function seriesStyle( name: string, meta: { engine?: string; format?: string } | null | undefined, ): SeriesStyle { const { engine, format } = seriesDims(name, meta); - const key = engine && format ? `${engine}:${format}` : name; - const keyed = KEYED[key]; + const keyed = engine && format ? KEYED[`${engine}:${format}`] : undefined; if (keyed) { return keyed; } - const color = MUTED[key] || (format ? MUTED[format] : undefined) || MUTED_FALLBACK; - return { color, width: MUTED_WIDTH }; + return (format && FORMAT[format]) || FALLBACK; } /** First 7 characters of a commit SHA. */ From 29d62d37f1adba18aef2eee32651925fc9451ce0 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Sat, 20 Jun 2026 00:31:11 -0400 Subject: [PATCH 08/10] feat(web): show vortex-file-compressed as "vortex" in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-disk Vortex format is stored as `vortex-file-compressed`; render it as plain `vortex` everywhere it is shown — filter chips, the chart legend and tooltip, and the summary-card rankings. Presentation only: series keys, filter allowlists, dataset labels, and overrides keep the real format string, so a displaySeriesLabel pass rewrites just the displayed text. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Connor Tsui --- web/components/Chart.tsx | 16 +++++++++++++++- web/components/FilterBar.tsx | 4 ++-- web/components/SummaryCard.tsx | 9 +++++---- web/lib/chart-format.test.ts | 20 ++++++++++++++++++++ web/lib/chart-format.ts | 21 +++++++++++++++++++++ 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/web/components/Chart.tsx b/web/components/Chart.tsx index 13925c6..4a7d0ad 100644 --- a/web/components/Chart.tsx +++ b/web/components/Chart.tsx @@ -38,6 +38,7 @@ import { pickDisplayUnit, predecessorValue, rangeTouchesUnloadedHistory, + displaySeriesLabel, seriesPassesFilter, seriesPassesGroupFilter, seriesStyle, @@ -331,7 +332,7 @@ function externalTooltipHandler(state: CardState, host: HTMLDivElement | null) { (r) => `
` + `` + - `${escapeHtml(r.label)}` + + `${escapeHtml(displaySeriesLabel(r.label))}` + `${escapeHtml(formatDisplayValue(r.raw, displayUnit))}` + r.deltaHtml + `
`, @@ -922,6 +923,19 @@ class ChartController { plugins: { legend: { position: legendPosition, + // Rewrite only the displayed legend text (vortex-file-compressed -> + // vortex); `dataset.label` stays the real key the toggle/filter use. + labels: { + generateLabels: (ci) => { + const items = Chart.defaults.plugins.legend.labels.generateLabels(ci); + for (const item of items) { + if (typeof item.text === 'string') { + item.text = displaySeriesLabel(item.text); + } + } + return items; + }, + }, // Wrap the default toggle to record the per-card override and // keep `dataset.hidden` in sync with the legend's visibility // flag; the filter passes write to `dataset.hidden`, so they diff --git a/web/components/FilterBar.tsx b/web/components/FilterBar.tsx index 9596ac2..1a13ae9 100644 --- a/web/components/FilterBar.tsx +++ b/web/components/FilterBar.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; -import { seedActiveFromAllowlist, type FilterUniverse } from '@/lib/chart-format'; +import { displayFormat, seedActiveFromAllowlist, type FilterUniverse } from '@/lib/chart-format'; import { getGlobalFilterSnapshot, initGlobalFilter, @@ -189,7 +189,7 @@ function FilterRow({ aria-pressed={isActive} onClick={() => onChipClick(dim, value)} > - {value} + {displayFormat(value)} ); })} diff --git a/web/components/SummaryCard.tsx b/web/components/SummaryCard.tsx index 765b9fb..21c592f 100644 --- a/web/components/SummaryCard.tsx +++ b/web/components/SummaryCard.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +import { displaySeriesLabel } from '@/lib/chart-format'; import { formatTimeNs } from '@/lib/format'; import type { Summary } from '@/lib/summary'; @@ -34,8 +35,8 @@ export function SummaryCard({ summary }: { summary?: Summary }) { {summary.rankings.map((item, idx) => (
#{idx + 1} - - {item.name} + + {displaySeriesLabel(item.name)} {formatTimeNs(item.time)} @@ -118,8 +119,8 @@ export function SummaryCard({ summary }: { summary?: Summary }) { {summary.rankings.map((item, idx) => (
#{idx + 1} - - {item.name} + + {displaySeriesLabel(item.name)} {item.score.toFixed(2)}x diff --git a/web/lib/chart-format.test.ts b/web/lib/chart-format.test.ts index d9d764a..e328983 100644 --- a/web/lib/chart-format.test.ts +++ b/web/lib/chart-format.test.ts @@ -11,6 +11,8 @@ import { colorFor, commitDateLabel, decimateSeries, + displayFormat, + displaySeriesLabel, escapeHtml, FETCH_TIMEOUT_MS, firstLine, @@ -168,6 +170,24 @@ describe('seriesStyle', () => { }); }); +describe('displayFormat / displaySeriesLabel', () => { + it('renames only vortex-file-compressed to vortex', () => { + expect(displayFormat('vortex-file-compressed')).toBe('vortex'); + expect(displayFormat('vortex-compact')).toBe('vortex-compact'); + expect(displayFormat('parquet')).toBe('parquet'); + }); + + it('rewrites the vortex token in any colon-delimited label', () => { + expect(displaySeriesLabel('datafusion:vortex-file-compressed')).toBe('datafusion:vortex'); + expect(displaySeriesLabel('duckdb:vortex-file-compressed')).toBe('duckdb:vortex'); + // Compression-time labels are format:op, so the token leads. + expect(displaySeriesLabel('vortex-file-compressed:encode')).toBe('vortex:encode'); + expect(displaySeriesLabel('vortex-file-compressed')).toBe('vortex'); + // Untouched labels pass through. + expect(displaySeriesLabel('datafusion:parquet')).toBe('datafusion:parquet'); + }); +}); + describe('commitDateLabel', () => { it('formats a commit timestamp as MMM D, YYYY', () => { expect(commitDateLabel(commit('abc'))).toBe('Jun 1, 2026'); diff --git a/web/lib/chart-format.ts b/web/lib/chart-format.ts index 7c48fcd..15b31c6 100644 --- a/web/lib/chart-format.ts +++ b/web/lib/chart-format.ts @@ -212,6 +212,27 @@ export function seriesStyle( return (format && FORMAT[format]) || FALLBACK; } +/** + * The user-facing display name for a format string. Vortex's on-disk format is + * stored as `vortex-file-compressed` everywhere in the data (series keys, filter + * allowlists, `series_meta`), but it is shown to users as plain `vortex`. This is + * a presentation-only rename: callers pass the real format to the data layer and + * route it through here ONLY when rendering. All other formats pass through. + */ +export function displayFormat(format: string): string { + return format === 'vortex-file-compressed' ? 'vortex' : format; +} + +/** + * The display version of a colon-delimited series label (`engine:format` or the + * compression-time `format:op`), rewriting each segment via [`displayFormat`] so + * the `vortex-file-compressed` token reads `vortex` wherever it appears while the + * underlying label (the dataset/override/lookup key) stays unchanged. + */ +export function displaySeriesLabel(label: string): string { + return label.split(':').map(displayFormat).join(':'); +} + /** First 7 characters of a commit SHA. */ export function shortSha(sha: unknown): string { return typeof sha === 'string' ? sha.slice(0, 7) : String(sha); From 6e376c624abab5f9b720d09c1a79105c6864d8da Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Sat, 20 Jun 2026 00:35:29 -0400 Subject: [PATCH 09/10] style(web): prettier-format chart-format tests Co-Authored-By: Claude Opus 4.8 Signed-off-by: Connor Tsui --- web/lib/chart-format.test.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/web/lib/chart-format.test.ts b/web/lib/chart-format.test.ts index e328983..f4c6054 100644 --- a/web/lib/chart-format.test.ts +++ b/web/lib/chart-format.test.ts @@ -120,8 +120,14 @@ describe('seriesStyle', () => { const tag = (engine: string, format: string) => ({ engine, format }); it('makes the hero Vortex series the loudest: red/green, distinct per engine, thickest', () => { - const dfv = seriesStyle('datafusion:vortex-file-compressed', tag('datafusion', 'vortex-file-compressed')); - const dkv = seriesStyle('duckdb:vortex-file-compressed', tag('duckdb', 'vortex-file-compressed')); + const dfv = seriesStyle( + 'datafusion:vortex-file-compressed', + tag('datafusion', 'vortex-file-compressed'), + ); + const dkv = seriesStyle( + 'duckdb:vortex-file-compressed', + tag('duckdb', 'vortex-file-compressed'), + ); expect(dfv.color).toBe('#ef4444'); expect(dkv.color).toBe('#22c55e'); expect(dfv.color).not.toBe(dkv.color); @@ -130,7 +136,10 @@ describe('seriesStyle', () => { }); it('puts Parquet a notch under Vortex (blue, thinner than the hero)', () => { - const vortex = seriesStyle('datafusion:vortex-file-compressed', tag('datafusion', 'vortex-file-compressed')); + const vortex = seriesStyle( + 'datafusion:vortex-file-compressed', + tag('datafusion', 'vortex-file-compressed'), + ); const parquet = seriesStyle('datafusion:parquet', tag('datafusion', 'parquet')); expect(parquet.color).toBe('#38bdf8'); expect(parquet.width).toBeLessThan(vortex.width); @@ -165,7 +174,10 @@ describe('seriesStyle', () => { it('falls back to a muted color + width for an unknown series', () => { const unknown = seriesStyle('datafusion:mystery', tag('datafusion', 'mystery')); expect(unknown.color).toBe('#94a3b8'); - const hero = seriesStyle('datafusion:vortex-file-compressed', tag('datafusion', 'vortex-file-compressed')); + const hero = seriesStyle( + 'datafusion:vortex-file-compressed', + tag('datafusion', 'vortex-file-compressed'), + ); expect(unknown.width).toBeLessThan(hero.width); }); }); From 740ad1446a6d5519ae68da18371b6aee9439ab8d Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Sat, 20 Jun 2026 00:41:35 -0400 Subject: [PATCH 10/10] feat(web): layer series so the hero comparison draws on top Set each dataset's Chart.js draw order by importance: Vortex in front, then Parquet, vortex-compact, the duckdb native format, lance, and arrow at the back, with datafusion drawn ahead of duckdb within a format. The format term dominates the engine term so the whole Vortex pair stays in front of the whole Parquet pair. Lower order renders on top, and it also orders the legend the same way. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Connor Tsui --- web/components/Chart.tsx | 4 ++++ web/lib/chart-format.test.ts | 30 ++++++++++++++++++++++++++++++ web/lib/chart-format.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/web/components/Chart.tsx b/web/components/Chart.tsx index 4a7d0ad..175425b 100644 --- a/web/components/Chart.tsx +++ b/web/components/Chart.tsx @@ -39,6 +39,7 @@ import { predecessorValue, rangeTouchesUnloadedHistory, displaySeriesLabel, + seriesOrder, seriesPassesFilter, seriesPassesGroupFilter, seriesStyle, @@ -246,6 +247,9 @@ function buildDatasets(payload: NormalizedChartPayload): BenchDataset[] { borderColor: style.color, backgroundColor: `${style.color}20`, borderWidth: style.width, + // Layer the hero series on top: Vortex in front ... arrow at the back, + // datafusion ahead of duckdb. Lower `order` renders on top in Chart.js. + order: seriesOrder(name, seriesMeta), spanGaps: true, tension: 0, pointRadius: 2, diff --git a/web/lib/chart-format.test.ts b/web/lib/chart-format.test.ts index f4c6054..198e792 100644 --- a/web/lib/chart-format.test.ts +++ b/web/lib/chart-format.test.ts @@ -34,6 +34,7 @@ import { predecessorValue, rangeTouchesUnloadedHistory, seedActiveFromAllowlist, + seriesOrder, seriesPassesFilter, seriesPassesGroupFilter, seriesStyle, @@ -182,6 +183,35 @@ describe('seriesStyle', () => { }); }); +describe('seriesOrder', () => { + const tag = (engine: string, format: string) => ({ engine, format }); + const ord = (engine: string, format: string) => + seriesOrder(`${engine}:${format}`, tag(engine, format)); + + it('layers formats front-to-back: vortex, parquet, vortex-compact, duckdb, lance, arrow', () => { + const ranks = [ + ord('datafusion', 'vortex-file-compressed'), + ord('datafusion', 'parquet'), + ord('datafusion', 'vortex-compact'), + ord('duckdb', 'duckdb'), + ord('datafusion', 'lance'), + ord('datafusion', 'arrow'), + ]; + const sorted = [...ranks].sort((a, b) => a - b); + expect(ranks).toEqual(sorted); + }); + + it('draws datafusion ahead of duckdb within a format', () => { + expect(ord('datafusion', 'vortex-file-compressed')).toBeLessThan( + ord('duckdb', 'vortex-file-compressed'), + ); + }); + + it('keeps the whole Vortex pair in front of the whole Parquet pair', () => { + expect(ord('duckdb', 'vortex-file-compressed')).toBeLessThan(ord('datafusion', 'parquet')); + }); +}); + describe('displayFormat / displaySeriesLabel', () => { it('renames only vortex-file-compressed to vortex', () => { expect(displayFormat('vortex-file-compressed')).toBe('vortex'); diff --git a/web/lib/chart-format.ts b/web/lib/chart-format.ts index 15b31c6..6ce8aa4 100644 --- a/web/lib/chart-format.ts +++ b/web/lib/chart-format.ts @@ -212,6 +212,39 @@ export function seriesStyle( return (format && FORMAT[format]) || FALLBACK; } +/** Format draw priority, front (low) to back (high) -- Chart.js renders a lower + * `order` on top. Vortex sits in front, arrow at the back. */ +const FORMAT_ORDER: Record = { + 'vortex-file-compressed': 0, + parquet: 1, + 'vortex-compact': 2, + duckdb: 3, + lance: 4, + arrow: 5, +}; +const FORMAT_ORDER_FALLBACK = 6; + +/** Engine draw priority within a format: datafusion is layered ahead of duckdb. */ +const ENGINE_ORDER: Record = { datafusion: 0, duckdb: 1 }; + +/** + * The Chart.js `order` for a series (lower renders on top). Series are layered by + * format importance first -- Vortex in front, then Parquet, vortex-compact, the + * duckdb native format, lance, and arrow at the back -- and within a format + * datafusion is drawn ahead of duckdb. The format term dominates the engine term + * so the whole Vortex pair stays in front of the whole Parquet pair. + */ +export function seriesOrder( + name: string, + meta: { engine?: string; format?: string } | null | undefined, +): number { + const { engine, format } = seriesDims(name, meta); + const formatRank = + format && format in FORMAT_ORDER ? FORMAT_ORDER[format] : FORMAT_ORDER_FALLBACK; + const engineRank = engine && engine in ENGINE_ORDER ? ENGINE_ORDER[engine] : 0; + return formatRank * 10 + engineRank; +} + /** * The user-facing display name for a format string. Vortex's on-disk format is * stored as `vortex-file-compressed` everywhere in the data (series keys, filter