diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml index 2ae5f28..810b8e2 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,11 @@ 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 }} @@ -51,10 +61,48 @@ 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 + # 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 < 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 +136,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 +155,66 @@ 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 + + # Flip the deployment to success and attach the preview URL, which is what + # turns the in-progress status into the clickable "View deployment" button. + - name: Mark preview deployment ready + if: ${{ success() && github.event_name == 'pull_request' && steps.deployment.outputs.id != '' }} + env: + GH_TOKEN: ${{ github.token }} + DEPLOY_ID: ${{ steps.deployment.outputs.id }} + DEPLOY_URL: ${{ steps.deploy.outputs.url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -Eeuo pipefail + gh api "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOY_ID}/statuses" --method POST --input - >/dev/null </dev/null <; - /** 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 +172,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 +222,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); + // 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 @@ -245,9 +244,12 @@ function buildDatasets( label: name, data, rawData: rawValues, - borderColor: color, - backgroundColor: `${color}20`, - borderWidth: 1.5, + 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, @@ -334,7 +336,7 @@ function externalTooltipHandler(state: CardState, host: HTMLDivElement | null) { (r) => `
` + `` + - `${escapeHtml(r.label)}` + + `${escapeHtml(displaySeriesLabel(r.label))}` + `${escapeHtml(formatDisplayValue(r.raw, displayUnit))}` + r.deltaHtml + `
`, @@ -415,7 +417,6 @@ class ChartController { payload: null, ui: { y: 'linear', scope: DEFAULT_VISIBLE }, overrides: {}, - seriesColors: new Map(), displayUnit: IDENTITY_UNIT, fullLoaded: false, initialFetchEntry: null, @@ -789,20 +790,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 +842,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 +905,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 +917,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. @@ -938,6 +927,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 @@ -1122,8 +1124,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/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 6163803..198e792 100644 --- a/web/lib/chart-format.test.ts +++ b/web/lib/chart-format.test.ts @@ -9,7 +9,10 @@ import { clampRangeWindow, collectAllValues, colorFor, + commitDateLabel, decimateSeries, + displayFormat, + displaySeriesLabel, escapeHtml, FETCH_TIMEOUT_MS, firstLine, @@ -31,8 +34,10 @@ import { predecessorValue, rangeTouchesUnloadedHistory, seedActiveFromAllowlist, + seriesOrder, seriesPassesFilter, seriesPassesGroupFilter, + seriesStyle, shortDate, shortSha, singleSearchParam, @@ -112,6 +117,136 @@ describe('small formatting helpers', () => { }); }); +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'), + ); + 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')); + expect(dfv.width).toBeGreaterThan(muted.width); + }); + + 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 parquet = seriesStyle('datafusion:parquet', tag('datafusion', 'parquet')); + expect(parquet.color).toBe('#38bdf8'); + expect(parquet.width).toBeLessThan(vortex.width); + }); + + 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('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-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-file-compressed', + tag('datafusion', 'vortex-file-compressed'), + ); + expect(unknown.width).toBeLessThan(hero.width); + }); +}); + +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'); + 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'); + }); + + 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 +272,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 +312,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..6ce8aa4 100644 --- a/web/lib/chart-format.ts +++ b/web/lib/chart-format.ts @@ -119,6 +119,153 @@ export function assignStableColors( return next; } +// --------------------------------------------------------------------------- +// 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. +// +// 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. +// --------------------------------------------------------------------------- + +/** 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.6; +const COMPACT_WIDTH = 1.9; +const SECONDARY_WIDTH = 2.0; +const NATIVE_WIDTH = 1.7; +const MUTED_WIDTH = 1.4; + +/** 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-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 +}; + +/** 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 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. */ +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 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 keyed = engine && format ? KEYED[`${engine}:${format}`] : undefined; + if (keyed) { + return keyed; + } + 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 + * 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); @@ -183,6 +330,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 +490,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 +505,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