diff --git a/src/app/(bench)/bench/chart/[slug]/page.tsx b/src/app/(bench)/bench/chart/[slug]/page.tsx index efdb5c2f..2bfbebdf 100644 --- a/src/app/(bench)/bench/chart/[slug]/page.tsx +++ b/src/app/(bench)/bench/chart/[slug]/page.tsx @@ -6,13 +6,14 @@ import { notFound, permanentRedirect, redirect } from 'next/navigation'; import { cache } from 'react'; import { Chart } from '@/bench/components/Chart'; +import { ChartUnit } from '@/bench/components/ChartUnit'; import { Footer } from '@/bench/components/Footer'; import { BenchHeader } from '@/bench/components/BenchHeader'; import { canonicalFilterCsv, + chartUnitLabel, parseFilterCsv, singleSearchParam, - unitKindLabel, } from '@/bench/lib/chart-format'; import { cachedDefaultChartPayload, cachedFilterUniverse } from '@/bench/lib/data-cache'; import { chartPayload, type ChartResponse } from '@/bench/lib/queries'; @@ -204,7 +205,7 @@ export default async function ChartPage({

{'unit: '} - {unitKindLabel(chart.unit_kind)} + {' · '} {seriesCount} series · {commitCount} commit{commitCount !== 1 ? 's' : ''}

diff --git a/src/app/(bench)/bench/page.tsx b/src/app/(bench)/bench/page.tsx index a570c2ff..175e5462 100644 --- a/src/app/(bench)/bench/page.tsx +++ b/src/app/(bench)/bench/page.tsx @@ -14,7 +14,7 @@ import { OpenGroupsScript } from '@/bench/components/OpenGroupsScript'; import { groupAnchor, groupAnchors } from '@/bench/lib/anchor'; import { type FilterUniverse, parseFilterCsv, singleSearchParam } from '@/bench/lib/chart-format'; import { cachedFilterUniverse, cachedGroups } from '@/bench/lib/data-cache'; -import { parseGroupFilter } from '@/bench/lib/group-filter'; +import { NO_GROUP_FILTER_CHOICES, parseGroupFilter } from '@/bench/lib/group-filter'; import type { Group } from '@/bench/lib/queries'; import { settlesWithin } from '@/bench/lib/settles-within'; import { BENCH_URL, datasetJsonLd } from '@/bench/lib/site-files'; @@ -127,6 +127,7 @@ function HomeContent({ data: [params, groups, universe] }: { data: LandingData } return ( <> 0} universe={universe} initialEngines={initialEngines} initialFormats={initialFormats} @@ -167,11 +168,10 @@ function HomeContent({ data: [params, groups, universe] }: { data: LandingData } anchor={anchors[i] ?? groupAnchor(group.name)} startIndex={startIndex} universe={universe} - initialHiddenSeries={ - ownsInitialFilter ? (initialGroupFilter?.hiddenSeries ?? []) : [] - } - initialShownSeries={ - ownsInitialFilter ? (initialGroupFilter?.shownSeries ?? []) : [] + initialFilter={ + ownsInitialFilter && initialGroupFilter !== null + ? initialGroupFilter + : NO_GROUP_FILTER_CHOICES } /> ); diff --git a/src/bench/components/Chart.lazy-hydration.test.tsx b/src/bench/components/Chart.lazy-hydration.test.tsx index bf1299a7..459e6a6f 100644 --- a/src/bench/components/Chart.lazy-hydration.test.tsx +++ b/src/bench/components/Chart.lazy-hydration.test.tsx @@ -13,6 +13,7 @@ import { FETCH_TIMEOUT_MS } from '@/bench/lib/chart-format'; import { PAGE_LEAVE_GRACE_MS } from '@/bench/lib/page-leave'; import { bundleQueue, hydrationQueue, resetPayloadCache } from '@/bench/lib/chart-store'; import { defined } from '@/lib/defined'; +import { NO_GROUP_FILTER_CHOICES } from '@/bench/lib/group-filter'; vi.mock('@/bench/lib/chart-js', () => ({ loadChartJs: () => new Promise(() => {}), @@ -607,8 +608,7 @@ describe('PR-5.0.97 group-bundle hydration', () => { ]} startIndex={0} universe={{ engines: [], formats: [] }} - initialHiddenSeries={[]} - initialShownSeries={[]} + initialFilter={NO_GROUP_FILTER_CHOICES} />, ); }); diff --git a/src/bench/components/Chart.loading.test.tsx b/src/bench/components/Chart.loading.test.tsx index 8772be24..7a08ec54 100644 --- a/src/bench/components/Chart.loading.test.tsx +++ b/src/bench/components/Chart.loading.test.tsx @@ -8,7 +8,8 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Chart } from '@/bench/components/Chart'; -import { FETCH_TIMEOUT_MS } from '@/bench/lib/chart-format'; +import { ChartUnit } from '@/bench/components/ChartUnit'; +import { chartUnitLabel, FETCH_TIMEOUT_MS } from '@/bench/lib/chart-format'; import { PAGE_LEAVE_GRACE_MS } from '@/bench/lib/page-leave'; import { fullHistoryQueue } from '@/bench/lib/chart-store'; import { loadChartJs } from '@/bench/lib/chart-js'; @@ -200,6 +201,72 @@ describe('Chart opt-in full-history loading', () => { expect(chip?.textContent).toBe('all 3,572'); }); + // The chart page's meta line names the axis's unit, so it follows the axis + // when the full history's older values need a larger unit. + it('the chart page unit follows the axis when full history loads', async () => { + class StubChart { + data: { labels: unknown[]; datasets: unknown[] }; + options: Record; + constructor( + _canvas: HTMLCanvasElement, + config: { + data: { labels: unknown[]; datasets: unknown[] }; + options: Record; + }, + ) { + this.data = { labels: config.data.labels ?? [], datasets: config.data.datasets ?? [] }; + this.options = config.options ?? {}; + } + isDatasetVisible(): boolean { + return true; + } + update(): void {} + destroy(): void {} + } + vi.mocked(loadChartJs).mockResolvedValueOnce(StubChart as never); + let resolveFull: (r: Response) => void = () => {}; + responders.push({ + match: (u) => u.includes('n=all'), + respond: () => + new Promise((res) => { + resolveFull = res; + }), + }); + const slug = 'qm.eyJrIjoidW5pdCJ9'; + const unitHost = document.createElement('p'); + container.after(unitHost); + const unitRoot = createRoot(unitHost); + await act(async () => { + unitRoot.render(); + }); + const chip = await renderOpenGroup(slug); + await act(async () => { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + }); + expect(unitHost.textContent).toBe('ns'); + + const full = completePayload(3572); + full.series['vortex/nvme'] = full.series['vortex/nvme'].map((v) => v * 1_000_000); + await act(async () => { + chip?.click(); + await Promise.resolve(); + }); + await act(async () => { + resolveFull(jsonResponse(full)); + for (let i = 0; i < 4; i++) { + await Promise.resolve(); + } + }); + expect(unitHost.textContent).toBe(chartUnitLabel(full as never)); + expect(unitHost.textContent).not.toBe('ns'); + await act(async () => { + unitRoot.unmount(); + }); + unitHost.remove(); + }); + it('a failed full fetch surfaces a retry affordance', async () => { let rejectFull: (e: unknown) => void = () => {}; responders.push({ diff --git a/src/bench/components/Chart.tsx b/src/bench/components/Chart.tsx index 210cef80..7ee80bd5 100644 --- a/src/bench/components/Chart.tsx +++ b/src/bench/components/Chart.tsx @@ -134,7 +134,15 @@ export function Chart({ slug, name, index, groupSlug, initialPayload }: ChartIsl // biome-ignore lint/correctness/useExhaustiveDependencies: the filter state is the trigger; `applyFilters` reads the stores itself useEffect(() => { controllerRef.current?.applyFilters(); - }, [globalFilter, groupState.hiddenSeries, groupState.shownSeries]); + }, [ + globalFilter, + groupState.hiddenSeries, + groupState.shownSeries, + groupState.hiddenEngines, + groupState.hiddenFormats, + groupState.shownEngines, + groupState.shownFormats, + ]); // Broadcast the per-group Y override to non-sticky charts; `null` (the // resting default and the post-Reset state) reverts to linear. diff --git a/src/bench/components/ChartUnit.tsx b/src/bench/components/ChartUnit.tsx new file mode 100644 index 00000000..06c208d8 --- /dev/null +++ b/src/bench/components/ChartUnit.tsx @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +'use client'; + +import { useCallback, useSyncExternalStore } from 'react'; + +import { getChartUnitLabel, subscribeChartUnitLabel } from '@/bench/lib/chart-store'; + +/** + * The chart page's unit, in its meta line. The server renders the unit of the + * window it sent (`initial`); once the chart draws, this follows the unit the + * y-axis is drawn in, which changes when the full history loads and its older + * values need a larger unit. + */ +export function ChartUnit({ slug, initial }: { slug: string; initial: string }) { + const subscribe = useCallback((cb: () => void) => subscribeChartUnitLabel(slug, cb), [slug]); + const label = useSyncExternalStore( + subscribe, + () => getChartUnitLabel(slug) ?? initial, + () => initial, + ); + return {label}; +} diff --git a/src/bench/components/GroupBody.test.tsx b/src/bench/components/GroupBody.test.tsx index f0f3e37a..dd89f08d 100644 --- a/src/bench/components/GroupBody.test.tsx +++ b/src/bench/components/GroupBody.test.tsx @@ -26,6 +26,7 @@ import { import { groupBodiesReady, resetGroupBodies } from '@/bench/lib/group-bodies'; import { takeOpenGroupsBootstrap } from '@/bench/lib/open-groups'; import type { Group } from '@/bench/lib/queries'; +import { NO_GROUP_FILTER_CHOICES } from '@/bench/lib/group-filter'; // The real island is covered by the `Chart.*` suites; here a bare card stands // in for it, so the body's own mount, fetch and abort behavior is all that runs. @@ -49,8 +50,7 @@ const PROPS: GroupBodyProps = { ], startIndex: 5, universe: { engines: ['datafusion', 'duckdb'], formats: ['parquet', 'vortex'] }, - initialHiddenSeries: ['duckdb:parquet'], - initialShownSeries: [], + initialFilter: { ...NO_GROUP_FILTER_CHOICES, hiddenSeries: ['duckdb:parquet'] }, }; function jsonResponse(body: unknown): Response { @@ -76,7 +76,7 @@ describe('GroupBody', () => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; resetPayloadCache(); resetGroupBodies(); - initGroupFilter('g', [], []); + initGroupFilter('g', NO_GROUP_FILTER_CHOICES); urls = []; signals = []; responders = []; diff --git a/src/bench/components/GroupBody.tsx b/src/bench/components/GroupBody.tsx index be2a1a33..ea2d69b2 100644 --- a/src/bench/components/GroupBody.tsx +++ b/src/bench/components/GroupBody.tsx @@ -15,6 +15,7 @@ import { noteGroupBodyOpen, seedGroupFilterOnce, } from '@/bench/lib/group-bodies'; +import type { GroupFilterChoices } from '@/bench/lib/group-filter'; import type { ChartLink } from '@/bench/lib/queries'; /** Props for one group's body. */ @@ -27,10 +28,8 @@ export interface GroupBodyProps { startIndex: number; /** Engine and format values for the toolbar's macro chips. */ universe: FilterUniverse; - /** Series the page URL hides in this group (`?group=…&hide=…`). */ - initialHiddenSeries: string[]; - /** Series the page URL shows in this group (`?group=…&show=…`). */ - initialShownSeries: string[]; + /** The filter choices the page URL gives this group (`?group=…&hide=…`). */ + initialFilter: GroupFilterChoices; } /** @@ -58,8 +57,7 @@ export function GroupBody({ charts, startIndex, universe, - initialHiddenSeries, - initialShownSeries, + initialFilter, }: GroupBodyProps) { const base = useBenchBase(); const ref = useRef(null); @@ -67,11 +65,11 @@ export function GroupBody({ // effect below opens it before paint when the disclosure is already open. const [open, setOpen] = useState(false); - // Restore a shared group permalink's series overrides, once per page load, + // Restore a shared group permalink's filter choices, once per page load, // so a remount never overwrites the user's later changes. useEffect(() => { - seedGroupFilterOnce(groupSlug, initialHiddenSeries, initialShownSeries); - }, [groupSlug, initialHiddenSeries, initialShownSeries]); + seedGroupFilterOnce(groupSlug, initialFilter); + }, [groupSlug, initialFilter]); useLayoutEffect(() => { const details = ref.current diff --git a/src/bench/components/GroupPermalink.test.tsx b/src/bench/components/GroupPermalink.test.tsx index c204032a..86056bdb 100644 --- a/src/bench/components/GroupPermalink.test.tsx +++ b/src/bench/components/GroupPermalink.test.tsx @@ -10,23 +10,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GroupPermalink, groupPermalinkUrl } from '@/bench/components/GroupPermalink'; import { + applyGroupMacro, initGlobalFilter, initGroupFilter, noteGroupSeries, + resetGroup, toggleGroupSeries, } from '@/bench/lib/chart-store'; +import { NO_GROUP_FILTER_CHOICES } from '@/bench/lib/group-filter'; describe('groupPermalinkUrl', () => { it('joins origin, pathname, and the readable anchor fragment', () => { const loc = { origin: 'https://bench.vortex.dev', pathname: '/' }; - expect(groupPermalinkUrl('random-access', [], [], { ...loc, search: '' })).toBe( - 'https://bench.vortex.dev/#random-access', - ); + expect( + groupPermalinkUrl('random-access', NO_GROUP_FILTER_CHOICES, { ...loc, search: '' }), + ).toBe('https://bench.vortex.dev/#random-access'); }); it('percent-encodes fragment-hostile anchor characters defensively', () => { const loc = { origin: 'https://bench.vortex.dev', pathname: '/' }; - expect(groupPermalinkUrl('a#b c', [], [], { ...loc, search: '' })).toBe( + expect(groupPermalinkUrl('a#b c', NO_GROUP_FILTER_CHOICES, { ...loc, search: '' })).toBe( 'https://bench.vortex.dev/#a%23b%20c', ); }); @@ -35,10 +38,24 @@ describe('groupPermalinkUrl', () => { const loc = { origin: 'https://bench.vortex.dev', pathname: '/', - search: '?engine=duckdb&group=old&hide=old-series&show=old-shown', + search: + '?engine=duckdb&group=old&hide=old-series&show=old-shown&hide-engine=old&hide-format=old&show-engine=old&show-format=old', }; - expect(groupPermalinkUrl('random-access', ['z series', 'a'], ['lance'], loc)).toBe( - 'https://bench.vortex.dev/?engine=duckdb&group=random-access&hide=a&hide=z+series&show=lance#random-access', + expect( + groupPermalinkUrl( + 'random-access', + { + hiddenSeries: ['z series', 'a'], + shownSeries: ['lance'], + hiddenEngines: ['duckdb'], + hiddenFormats: ['vortex', 'parquet'], + shownEngines: ['datafusion'], + shownFormats: ['lance'], + }, + loc, + ), + ).toBe( + 'https://bench.vortex.dev/?engine=duckdb&group=random-access&hide=a&hide=z+series&show=lance&hide-engine=duckdb&hide-format=parquet&hide-format=vortex&show-engine=datafusion&show-format=lance#random-access', ); }); }); @@ -88,7 +105,8 @@ describe('GroupPermalink click behavior', () => { vi.useRealTimers(); Reflect.deleteProperty(navigator, 'clipboard'); window.history.replaceState(null, '', '/'); - initGroupFilter('random_access', [], []); + initGroupFilter('random_access', NO_GROUP_FILTER_CHOICES); + resetGroup('random_access'); }); /** Mount the link inside a real `
`/`` host, as on the @@ -259,6 +277,23 @@ describe('GroupPermalink click behavior', () => { ]); }); + // The engine goes into the link as an engine, so it also hides the series of + // charts that have not loaded yet when someone opens the link. + it('copies an engine or format choice as that engine or format', async () => { + const { link } = mount(); + noteGroupSeries('random_access', { + 'datafusion:parquet': { engine: 'datafusion', format: 'parquet' }, + 'duckdb:parquet': { engine: 'duckdb', format: 'parquet' }, + }); + applyGroupMacro('random_access', 'engine', 'datafusion'); + + await click(link); + + expect(copiedText).toEqual([ + `${window.location.origin}/?group=random-access&hide-engine=datafusion#random-access`, + ]); + }); + it('preserves a local override that restores a globally hidden series', async () => { const { link } = mount(); initGlobalFilter({ engines: [], formats: ['parquet', 'lance'] }, [], []); diff --git a/src/bench/components/GroupPermalink.tsx b/src/bench/components/GroupPermalink.tsx index 1b3a8923..0e57e862 100644 --- a/src/bench/components/GroupPermalink.tsx +++ b/src/bench/components/GroupPermalink.tsx @@ -9,7 +9,12 @@ import { useBenchPath } from '@/bench/components/BenchBase'; import { getGroupSnapshot } from '@/bench/lib/chart-store'; import { GROUP_FILTER_PARAM, + type GroupFilterChoices, + HIDDEN_ENGINE_PARAM, + HIDDEN_FORMAT_PARAM, HIDDEN_SERIES_PARAM, + SHOWN_ENGINE_PARAM, + SHOWN_FORMAT_PARAM, SHOWN_SERIES_PARAM, } from '@/bench/lib/group-filter'; import { CheckIcon, LinkIcon } from '@/bench/components/icons'; @@ -20,9 +25,12 @@ const COPIED_FEEDBACK_MS = 1500; /** * Build the shareable URL for a group: the landing page with the group's * human-readable anchor (see `lib/anchor.ts`) as the fragment, so the link - * opens that one group whatever else is open now. When the group has local - * series overrides, repeated `hide` and `show` parameters preserve them and a - * `group` parameter carries the same readable anchor. Other query parameters, + * opens that one group whatever else is open now. When the group's filter has + * choices, repeated `hide`, `show`, `hide-engine`, `hide-format`, + * `show-engine` and `show-format` parameters preserve each row's, and a `group` parameter carries the same readable + * anchor. The Engine and Format rows go as engines and formats, not as the + * series they hide now, so they also hide the series of charts that have not + * loaded yet. Other query parameters, * such as the global engine and format filters, stay unchanged. * [`GroupSection`] stamps the anchor as the section's `id`, so the fragment * names a real element on the page. On load, the pre-paint script @@ -38,21 +46,28 @@ const COPIED_FEEDBACK_MS = 1500; */ export function groupPermalinkUrl( anchor: string, - hiddenSeries: readonly string[], - shownSeries: readonly string[], + choices: GroupFilterChoices, loc: Pick, ): string { const url = new URL(`${loc.origin}${loc.pathname}${loc.search}`); + const params: [string, readonly string[]][] = [ + [HIDDEN_SERIES_PARAM, choices.hiddenSeries], + [SHOWN_SERIES_PARAM, choices.shownSeries], + [HIDDEN_ENGINE_PARAM, choices.hiddenEngines], + [HIDDEN_FORMAT_PARAM, choices.hiddenFormats], + [SHOWN_ENGINE_PARAM, choices.shownEngines], + [SHOWN_FORMAT_PARAM, choices.shownFormats], + ]; url.searchParams.delete(GROUP_FILTER_PARAM); - url.searchParams.delete(HIDDEN_SERIES_PARAM); - url.searchParams.delete(SHOWN_SERIES_PARAM); - if (hiddenSeries.length > 0 || shownSeries.length > 0) { + for (const [name] of params) { + url.searchParams.delete(name); + } + if (params.some(([, values]) => values.length > 0)) { url.searchParams.set(GROUP_FILTER_PARAM, anchor); - for (const label of [...new Set(hiddenSeries)].sort()) { - url.searchParams.append(HIDDEN_SERIES_PARAM, label); - } - for (const label of [...new Set(shownSeries)].sort()) { - url.searchParams.append(SHOWN_SERIES_PARAM, label); + for (const [name, values] of params) { + for (const value of [...new Set(values)].sort()) { + url.searchParams.append(name, value); + } } } return `${url.origin}${url.pathname}${url.search}#${encodeURIComponent(anchor)}`; @@ -102,8 +117,7 @@ export function GroupPermalink({ const path = useBenchPath(); const [href, setHref] = useState(path(`/#${encodeURIComponent(anchor)}`)); const permalink = useCallback((): string => { - const snapshot = getGroupSnapshot(groupSlug); - return groupPermalinkUrl(anchor, snapshot.hiddenSeries, snapshot.shownSeries, window.location); + return groupPermalinkUrl(anchor, getGroupSnapshot(groupSlug), window.location); }, [anchor, groupSlug]); const refreshHref = useCallback((): void => { setHref(permalink().slice(window.location.origin.length)); diff --git a/src/bench/components/GroupSection.tsx b/src/bench/components/GroupSection.tsx index 9dee955c..9acdbf49 100644 --- a/src/bench/components/GroupSection.tsx +++ b/src/bench/components/GroupSection.tsx @@ -10,12 +10,10 @@ import { InfoIcon } from '@/bench/components/icons'; import { SummaryCard } from '@/bench/components/SummaryCard'; import { groupDocUrl } from '@/bench/lib/benchmark-docs'; import type { FilterUniverse } from '@/bench/lib/chart-format'; +import { type GroupFilterChoices, NO_GROUP_FILTER_CHOICES } from '@/bench/lib/group-filter'; import { groupHeadline } from '@/bench/lib/headline'; import type { Group } from '@/bench/lib/queries'; -const NO_INITIAL_HIDDEN_SERIES: string[] = []; -const NO_INITIAL_SHOWN_SERIES: string[] = []; - /** * One group's landing-page section, the server-component port of * `server/src/html/landing.rs`'s per-group markup. @@ -68,15 +66,13 @@ export function GroupSection({ anchor, startIndex, universe, - initialHiddenSeries = NO_INITIAL_HIDDEN_SERIES, - initialShownSeries = NO_INITIAL_SHOWN_SERIES, + initialFilter = NO_GROUP_FILTER_CHOICES, }: { group: Group; anchor: string; startIndex: number; universe: FilterUniverse; - initialHiddenSeries?: string[]; - initialShownSeries?: string[]; + initialFilter?: GroupFilterChoices; }) { const chartCount = group.charts.length; // The info blurb's long form: the benchmark's explainer doc in the monorepo, @@ -140,8 +136,7 @@ export function GroupSection({ charts={group.charts} startIndex={startIndex} universe={universe} - initialHiddenSeries={initialHiddenSeries} - initialShownSeries={initialShownSeries} + initialFilter={initialFilter} /> ); diff --git a/src/bench/components/GroupToolbar.rows.test.tsx b/src/bench/components/GroupToolbar.rows.test.tsx new file mode 100644 index 00000000..2bf61a7e --- /dev/null +++ b/src/bench/components/GroupToolbar.rows.test.tsx @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { GroupToolbar } from '@/bench/components/GroupToolbar'; +import { defined } from '@/lib/defined'; +import { + applyGroupMacro, + getGroupSnapshot, + initGlobalFilter, + noteGroupSeries, + toggleGroupSeries, +} from '@/bench/lib/chart-store'; + +const UNIVERSE = { engines: ['datafusion', 'duckdb'], formats: ['parquet', 'vortex'] }; + +// Each row's "all" chip turns its own row back on and leaves the other rows' +// choices alone. +describe("GroupToolbar's all chips", () => { + let root: Root | null = null; + const host = document.createElement('div'); + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + initGlobalFilter(UNIVERSE, null, null); + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + host.remove(); + }); + + /** Mount a group with one choice in each row, and click one row's "all" chip. */ + function clickAll(slug: string, dim: 'engine' | 'format' | 'series'): void { + noteGroupSeries(slug, { + 'datafusion:parquet': { engine: 'datafusion', format: 'parquet' }, + 'datafusion:vortex': { engine: 'datafusion', format: 'vortex' }, + 'duckdb:parquet': { engine: 'duckdb', format: 'parquet' }, + 'duckdb:vortex': { engine: 'duckdb', format: 'vortex' }, + }); + applyGroupMacro(slug, 'engine', 'datafusion'); + applyGroupMacro(slug, 'format', 'parquet'); + toggleGroupSeries(slug, 'duckdb:vortex'); + document.body.appendChild(host); + root = createRoot(host); + act(() => { + root?.render(); + }); + const chip = defined( + host.querySelector(`[data-group-filter="${dim}"][data-value="*"]`), + ); + act(() => chip.click()); + } + + it('in the Engine row clears only the engines', () => { + clickAll('rows-engine', 'engine'); + const snapshot = getGroupSnapshot('rows-engine'); + expect(snapshot.hiddenEngines).toEqual([]); + expect(snapshot.hiddenFormats).toEqual(['parquet']); + expect(snapshot.hiddenSeries).toEqual(['duckdb:vortex']); + }); + + it('in the Format row clears only the formats', () => { + clickAll('rows-format', 'format'); + const snapshot = getGroupSnapshot('rows-format'); + expect(snapshot.hiddenEngines).toEqual(['datafusion']); + expect(snapshot.hiddenFormats).toEqual([]); + expect(snapshot.hiddenSeries).toEqual(['duckdb:vortex']); + }); + + it('in the Series row clears only the series', () => { + clickAll('rows-series', 'series'); + const snapshot = getGroupSnapshot('rows-series'); + expect(snapshot.hiddenEngines).toEqual(['datafusion']); + expect(snapshot.hiddenFormats).toEqual(['parquet']); + expect(snapshot.hiddenSeries).toEqual([]); + }); +}); diff --git a/src/bench/components/GroupToolbar.test.tsx b/src/bench/components/GroupToolbar.test.tsx index efd40117..40a2f577 100644 --- a/src/bench/components/GroupToolbar.test.tsx +++ b/src/bench/components/GroupToolbar.test.tsx @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'; import { GroupToolbar } from '@/bench/components/GroupToolbar'; import { + applyGroupMacro, initGlobalFilter, noteChartRecentData, noteGroupSeries, @@ -97,3 +98,28 @@ describe('GroupToolbar cascading filters', () => { expect(html).toMatch(/data-group-filter="series" data-value="lance" aria-pressed="true"/); }); }); + +// Each row's chips show that row's choice: turning Parquet off leaves every +// engine chip on, though each engine then has a hidden series. +describe('GroupToolbar filter rows', () => { + it("shows each row's own choices", () => { + const slug = 'toolbar-independent-rows'; + initGlobalFilter(UNIVERSE, null, null); + noteGroupSeries(slug, { + 'datafusion:parquet': { engine: 'datafusion', format: 'parquet' }, + 'datafusion:vortex': { engine: 'datafusion', format: 'vortex' }, + 'duckdb:parquet': { engine: 'duckdb', format: 'parquet' }, + 'duckdb:vortex': { engine: 'duckdb', format: 'vortex' }, + }); + applyGroupMacro(slug, 'format', 'parquet'); + + const html = renderToStaticMarkup(); + expect(html).toMatch(/data-group-filter="engine" data-value="datafusion" aria-pressed="true"/); + expect(html).toMatch(/data-group-filter="engine" data-value="duckdb" aria-pressed="true"/); + expect(html).toMatch(/data-group-filter="format" data-value="parquet" aria-pressed="false"/); + expect(html).toMatch(/data-group-filter="format" data-value="vortex" aria-pressed="true"/); + expect(html).toMatch( + /data-group-filter="series" data-value="duckdb:parquet" aria-pressed="false"/, + ); + }); +}); diff --git a/src/bench/components/GroupToolbar.tsx b/src/bench/components/GroupToolbar.tsx index 01c3fe4e..50318fa6 100644 --- a/src/bench/components/GroupToolbar.tsx +++ b/src/bench/components/GroupToolbar.tsx @@ -9,9 +9,10 @@ import { FilterIcon } from '@/bench/components/icons'; import { DEFAULT_VISIBLE, type FilterUniverse } from '@/bench/lib/chart-format'; import { applyGroupMacro, - clearGroupSeriesFilter, + clearGroupFilterRow, getGlobalFilterSnapshot, getGroupSnapshot, + groupMacroIsActive, groupSeriesIsVisible, resetGroup, setGroupY, @@ -165,7 +166,6 @@ export function GroupToolbar({ groupSlug={groupSlug} snapshot={snapshot} globalSnapshot={globalSnapshot} - knownSeries={snapshot.knownSeries} />
Series @@ -184,7 +183,7 @@ export function GroupToolbar({ data-group-filter="series" data-value="*" aria-pressed="false" - onClick={() => clearGroupSeriesFilter(groupSlug)} + onClick={() => clearGroupFilterRow(groupSlug, 'series')} > all @@ -246,9 +245,10 @@ export function GroupToolbar({ /** * An engine/format macro row inside the per-group filter panel. A macro chip is - * active iff at least one known series matches this tag AND every match is - * currently visible; with no matching series the chip is inert and renders - * inactive so the dropdown does not falsely advertise irrelevant filters. + * active when its row does not hide it and at least one series it covers shows + * ([`groupMacroIsActive`]); with no matching series the chip is inert and + * renders inactive so the dropdown does not falsely advertise irrelevant + * filters. The row's "all" chip clears this row's choices only. */ function MacroRow({ label, @@ -257,7 +257,6 @@ function MacroRow({ groupSlug, snapshot, globalSnapshot, - knownSeries, }: { label: string; dim: 'engine' | 'format'; @@ -265,7 +264,6 @@ function MacroRow({ groupSlug: string; snapshot: ReturnType; globalSnapshot: ReturnType; - knownSeries: Record; }) { return (
@@ -276,15 +274,12 @@ function MacroRow({ data-group-filter={dim} data-value="*" aria-pressed="false" - onClick={() => clearGroupSeriesFilter(groupSlug)} + onClick={() => clearGroupFilterRow(groupSlug, dim)} > all {universe.map((value) => { - const matching = Object.keys(knownSeries).filter((l) => knownSeries[l]?.[dim] === value); - const isActive = - matching.length > 0 && - matching.every((value) => groupSeriesIsVisible(snapshot, value, globalSnapshot)); + const isActive = groupMacroIsActive(snapshot, dim, value, globalSnapshot); return (
))} @@ -257,9 +268,7 @@ export function SummaryCard({ summary }: { summary?: Summary | undefined }) { {item.score.toFixed(2)}x - - {item.measured > 0 ? formatTimeNs(item.totalRuntime) : '—'} - + {runtimeText(item)}
))} diff --git a/src/bench/components/Toolbar.test.tsx b/src/bench/components/Toolbar.test.tsx index 4d395f72..3bac4d06 100644 --- a/src/bench/components/Toolbar.test.tsx +++ b/src/bench/components/Toolbar.test.tsx @@ -8,9 +8,24 @@ import { Toolbar } from '@/bench/components/Toolbar'; const UNIVERSE = { engines: ['datafusion', 'duckdb'], formats: ['parquet', 'vortex'] }; +// Expand all and Collapse all open and close the landing page's groups, so +// they render only where the page asks for them: the chart page, the +// methodology page and the loading view have no groups. +describe('Toolbar group controls', () => { + it('renders Expand all and Collapse all only when the page has groups', () => { + const without = renderToStaticMarkup( + , + ); + expect(without).not.toContain('data-action="expand-all"'); + expect(without).not.toContain('data-action="collapse-all"'); + expect(without).toContain('data-role="global-filter-bar"'); + expect(renderToStaticMarkup()).not.toContain('Expand all'); + }); +}); + describe('Toolbar (server-rendered)', () => { const html = renderToStaticMarkup( - , + , ); // The site's header holds the page's title, the theme toggle and the GitHub button. diff --git a/src/bench/components/Toolbar.tsx b/src/bench/components/Toolbar.tsx index 37d9c9ef..701e0ded 100644 --- a/src/bench/components/Toolbar.tsx +++ b/src/bench/components/Toolbar.tsx @@ -12,6 +12,9 @@ import { setAllGroupsOpen } from '@/bench/lib/open-groups'; /** What a page passes for the filter dropdown. */ export type ToolbarProps = { + /** Draw Expand all and Collapse all: only the landing page, with its + * groups, passes it. */ + groupControls?: boolean; universe?: FilterUniverse | undefined; initialEngines?: string[] | null; initialFormats?: string[] | null; @@ -31,17 +34,24 @@ export type ToolbarProps = { * kept at least 300 ms apart. The native `toggle` event * (which also fires for scripted changes) is how each group body learns its * group opened, so no shared React tree is needed between the toolbar and the - * groups. On a page without groups (the chart page) the buttons do nothing. + * groups. The two buttons render only where the page passes `groupControls`: + * the landing page, when it has groups. The chart page, the methodology page + * and the loading view have no groups for them to open or close. * * The filter dropdown renders only when the universe has at least one chip, * matching v3's `show_filters` guard. Pages without chart data (or without the - * universe fetched) pass an empty universe and get the two buttons. + * universe fetched) pass an empty universe and get no dropdown. * * Without JavaScript none of the buttons can work, so each carries * `data-requires-js`, which hides it under the root's `no-js` class * (globals.css), as does the filter dropdown ([`FilterBar`]). */ -export function Toolbar({ universe, initialEngines, initialFormats }: ToolbarProps) { +export function Toolbar({ + groupControls = false, + universe, + initialEngines, + initialFormats, +}: ToolbarProps) { // The pre-paint script has swapped `no-js` for `js` already. If React ever // renders the root `` afresh (a root-level hydration failure does), it // puts the server's `no-js` back, which would hide every control that needs @@ -56,26 +66,30 @@ export function Toolbar({ universe, initialEngines, initialFormats }: ToolbarPro return ( // biome-ignore lint/a11y/useAriaPropsSupportedByRole: deliberate markup, as bench has always rendered the group controls.
- - + {groupControls && ( + <> + + + + )} {showFilters && ( ({ hidden: false }), + canvas: document.createElement('canvas'), + } as unknown as ChartJs; + const tooltip = { + opacity: 1, + caretX: 0, + caretY: 0, + dataPoints: [{ dataIndex: 1 }], + } as unknown as TooltipModel<'line'>; + externalTooltipHandler(state, host)({ chart, tooltip }); + return host; +} + +function deltaClass(host: HTMLDivElement): string | undefined { + return host.querySelector('.tt-delta')?.className; +} + +describe('the tooltip delta', () => { + it('marks a slower time as worse and a faster one as better', () => { + expect(deltaClass(tooltipFor('time_ns', 100, 120))).toBe('tt-delta tt-delta--worse'); + expect(deltaClass(tooltipFor('time_ns', 120, 100))).toBe('tt-delta tt-delta--better'); + }); + + it('marks a larger size as worse', () => { + expect(deltaClass(tooltipFor('bytes', 100, 120))).toBe('tt-delta tt-delta--worse'); + }); + + // Higher is better on a throughput chart. + it('marks a rise in throughput as better and a fall as worse', () => { + const rise = tooltipFor('throughput_mb_s', 100, 120); + expect(deltaClass(rise)).toBe('tt-delta tt-delta--better'); + expect(rise.querySelector('.tt-delta')?.textContent).toBe('+20.0%'); + expect(deltaClass(tooltipFor('throughput_mb_s', 120, 100))).toBe('tt-delta tt-delta--worse'); + }); + + it('leaves no change uncoloured', () => { + expect(deltaClass(tooltipFor('throughput_mb_s', 100, 100))).toBe('tt-delta'); + }); +}); diff --git a/src/bench/components/chart/tooltip.ts b/src/bench/components/chart/tooltip.ts index 6117e66e..26c4e43c 100644 --- a/src/bench/components/chart/tooltip.ts +++ b/src/bench/components/chart/tooltip.ts @@ -8,6 +8,7 @@ import { escapeHtml, firstLine, formatDisplayValue, + higherIsBetter, IDENTITY_UNIT, predecessorValue, shortDate, @@ -49,6 +50,8 @@ export function externalTooltipHandler(state: CardState, host: HTMLDivElement | const idx = firstDp.dataIndex; const commit = state.payload?.commits?.[idx] ?? null; const displayUnit = state.displayUnit ?? IDENTITY_UNIT; + // A rise is a gain on a throughput chart and a loss on every other one. + const riseIsBetter = higherIsBetter(state.payload?.unit_kind); // Build one row per dataset from each series' `rawData` so the tooltip // shows raw measurements even when LTTB nulled out `dataset.data[idx]`. @@ -71,12 +74,13 @@ export function externalTooltipHandler(state: CardState, host: HTMLDivElement | let deltaHtml = ''; if (prevRaw !== null && prevRaw !== 0) { const pct = ((raw - prevRaw) / prevRaw) * 100; + const better = riseIsBetter ? pct > 0 : pct < 0; const cls = - pct > 0 - ? 'tt-delta tt-delta--worse' - : pct < 0 + pct === 0 + ? 'tt-delta' + : better ? 'tt-delta tt-delta--better' - : 'tt-delta'; + : 'tt-delta tt-delta--worse'; const sign = pct > 0 ? '+' : ''; deltaHtml = `${sign}${pct.toFixed(1)}%`; } diff --git a/src/bench/lib/chart-format.test.ts b/src/bench/lib/chart-format.test.ts index d92c6248..18c4d7d2 100644 --- a/src/bench/lib/chart-format.test.ts +++ b/src/bench/lib/chart-format.test.ts @@ -7,6 +7,7 @@ import { canonicalHistory, chartAriaLabel, chartHasRecentData, + chartUnitLabel, chartPriority, clampRangeWindow, collectAllValues, @@ -48,7 +49,7 @@ import { truncate, visibleRange, } from './chart-format'; -import type { ChartResponse, CommitPoint } from './queries'; +import type { ChartResponse, CommitPoint, UnitKind } from './queries'; import { defined } from '@/lib/defined'; function commit(sha: string): CommitPoint { @@ -627,6 +628,27 @@ describe('pickDisplayUnit', () => { }); }); +// The chart page's meta line names the unit its chart's axis is drawn in. +describe('chartUnitLabel', () => { + const chart = (unit_kind: UnitKind, values: number[]) => ({ + unit_kind, + series: { vortex: values, parquet: [null, ...values] }, + }); + + it("names the axis's display unit, not the stored one", () => { + expect(chartUnitLabel(chart('time_ns', [4e6, 6e6]))).toBe('ms'); + expect(chartUnitLabel(chart('time_ns', [4e3]))).toBe('µs'); + expect(chartUnitLabel(chart('time_ns', [400]))).toBe('ns'); + expect(chartUnitLabel(chart('bytes', [3e9]))).toBe('GB'); + expect(chartUnitLabel(chart('throughput_mb_s', [100]))).toBe('MB/s'); + }); + + it('names a dimensionless kind, whose axis has no unit', () => { + expect(chartUnitLabel(chart('ratio', [1.5]))).toBe('ratio'); + expect(chartUnitLabel(chart('count', [3]))).toBe('count'); + }); +}); + describe('formatDisplayValue', () => { it('applies the locked multiplier, decimals, and suffix', () => { const unit = pickDisplayUnit('time_ns', [12e9]); diff --git a/src/bench/lib/chart-format.ts b/src/bench/lib/chart-format.ts index ddbd7125..07020635 100644 --- a/src/bench/lib/chart-format.ts +++ b/src/bench/lib/chart-format.ts @@ -404,9 +404,19 @@ export function singleSearchParam(v: string | string[] | undefined): string | nu return typeof v === 'string' ? v : null; } +/** + * Whether a larger value is the better result for a wire `unit_kind`. + * Throughput is best high; times and sizes, the units bench charts today, are + * best low. The chart tooltip colours each delta by it, and the chart page's + * description ranks series by it. + */ +export function higherIsBetter(unitKind: UnitKind | string | undefined): boolean { + return unitKind === 'throughput_mb_s'; +} + /** The short human label for a wire `unit_kind`, ported from the Rust - * `UnitKind::label` (used by the chart permalink page's meta line). */ -export function unitKindLabel(unitKind: UnitKind): string { + * `UnitKind::label`. */ +function unitKindLabel(unitKind: UnitKind): string { switch (unitKind) { case 'time_ns': return 'ns'; @@ -566,6 +576,24 @@ export function pickDisplayUnit( return IDENTITY_UNIT; } +/** + * The unit a chart's y-axis is drawn in, for the chart page's meta line: the + * display unit's suffix (`ms` for a chart stored in nanoseconds), or the + * kind's own word for a dimensionless kind, whose axis has no unit. + */ +export function displayUnitLabel(unit: DisplayUnit, unitKind: UnitKind): string { + return unit.suffix || unitKindLabel(unitKind); +} + +/** [`displayUnitLabel`] for the unit [`pickDisplayUnit`] picks from a payload's + * values, as the chart does when it first draws them. */ +export function chartUnitLabel(chart: Pick): string { + return displayUnitLabel( + pickDisplayUnit(chart.unit_kind, collectAllValues(chart)), + chart.unit_kind, + ); +} + /** * Tooltip formatter: applies the chart's locked display unit so the tooltip * value matches the y-axis tick numbers exactly. Raw `null`/`NaN` collapse to diff --git a/src/bench/lib/chart-store.test.ts b/src/bench/lib/chart-store.test.ts index da9b958d..6b57bc72 100644 --- a/src/bench/lib/chart-store.test.ts +++ b/src/bench/lib/chart-store.test.ts @@ -7,13 +7,14 @@ import { abortGroupBundle, applyGroupMacro, chartIsHiddenAsEmpty, - clearGroupSeriesFilter, + clearGroupFilterRow, ensureGroupBundle, getCachedPayload, getGlobalFilterSnapshot, getGroupSnapshot, groupSeriesIsVisible, hydrationQueue, + groupMacroIsActive, initGlobalFilter, initGroupFilter, noteChartRecentData, @@ -27,6 +28,7 @@ import { toggleGlobalFilterValue, toggleGroupSeries, } from './chart-store'; +import { NO_GROUP_FILTER_CHOICES } from '@/bench/lib/group-filter'; // The stores are module-scope singletons (one per tab, one per test file run); // each describe block uses its own group slug so state does not leak between @@ -35,6 +37,22 @@ import { const UNIVERSE = { engines: ['datafusion', 'duckdb'], formats: ['parquet', 'vortex'] }; +/** Every pairing of `UNIVERSE`'s engines and formats. */ +const FOUR_SERIES = { + 'datafusion:parquet': { engine: 'datafusion', format: 'parquet' }, + 'datafusion:vortex': { engine: 'datafusion', format: 'vortex' }, + 'duckdb:parquet': { engine: 'duckdb', format: 'parquet' }, + 'duckdb:vortex': { engine: 'duckdb', format: 'vortex' }, +}; + +/** The group's known series that show, sorted. */ +function visibleSeries(slug: string): string[] { + const snapshot = getGroupSnapshot(slug); + return Object.keys(snapshot.knownSeries) + .filter((label) => groupSeriesIsVisible(snapshot, label)) + .sort(); +} + describe('global filter store', () => { it('seeds every chip active with no URL allowlist', () => { initGlobalFilter(UNIVERSE, null, null); @@ -97,10 +115,21 @@ describe('per-group store', () => { it('restores a URL filter without discarding hydrated series metadata', () => { const slug = 'group-url-filter'; noteGroupSeries(slug, { a: { engine: 'duckdb' } }); - initGroupFilter(slug, ['b', 'a', 'b'], ['lance', 'lance']); + initGroupFilter(slug, { + hiddenSeries: ['b', 'a', 'b'], + shownSeries: ['lance', 'lance'], + hiddenEngines: ['datafusion', 'datafusion'], + hiddenFormats: ['parquet'], + shownEngines: ['duckdb'], + shownFormats: ['lance', 'parquet'], + }); const snap = getGroupSnapshot(slug); expect(snap.hiddenSeries).toEqual(['b', 'a']); expect(snap.shownSeries).toEqual(['lance']); + expect(snap.hiddenEngines).toEqual(['datafusion']); + expect(snap.hiddenFormats).toEqual([]); + expect(snap.shownEngines).toEqual(['duckdb']); + expect(snap.shownFormats).toEqual(['lance', 'parquet']); expect(snap.knownSeries).toEqual({ a: { engine: 'duckdb' } }); }); @@ -152,15 +181,29 @@ describe('per-group store', () => { 'duckdb:vortex': { engine: 'duckdb', format: 'vortex' }, 'datafusion:vortex': { engine: 'datafusion', format: 'vortex' }, }); - // All duckdb series visible: the macro hides them all. + const visible = () => visibleSeries(slug); + // A shown engine: the macro hides every series it matches. applyGroupMacro(slug, 'engine', 'duckdb'); - expect(getGroupSnapshot(slug).hiddenSeries.sort()).toEqual(['duckdb:parquet', 'duckdb:vortex']); - // Any match hidden: the macro shows them all. + expect(getGroupSnapshot(slug).hiddenEngines).toEqual(['duckdb']); + expect(visible()).toEqual(['datafusion:vortex']); + // A hidden engine: the macro shows them again. applyGroupMacro(slug, 'engine', 'duckdb'); - expect(getGroupSnapshot(slug).hiddenSeries).toEqual([]); + expect(getGroupSnapshot(slug).hiddenEngines).toEqual([]); + expect(visible()).toEqual(['datafusion:vortex', 'duckdb:parquet', 'duckdb:vortex']); // A macro with no matching series is inert. + const snap = getGroupSnapshot(slug); applyGroupMacro(slug, 'engine', 'unknown-engine'); + expect(getGroupSnapshot(slug)).toBe(snap); + }); + + it('lets a macro replace the series picks it covers', () => { + const slug = 'group-macro-over-series'; + noteGroupSeries(slug, FOUR_SERIES); + toggleGroupSeries(slug, 'duckdb:vortex'); + applyGroupMacro(slug, 'engine', 'duckdb'); + applyGroupMacro(slug, 'engine', 'duckdb'); expect(getGroupSnapshot(slug).hiddenSeries).toEqual([]); + expect(visibleSeries(slug)).toEqual(Object.keys(FOUR_SERIES).sort()); }); it('uses a macro to restore every match hidden by the global default', () => { @@ -172,35 +215,123 @@ describe('per-group store', () => { 'duckdb:lance': { engine: 'duckdb', format: 'lance' }, }); + expect(visibleSeries(slug)).toEqual([]); applyGroupMacro(slug, 'format', 'lance'); - expect(getGroupSnapshot(slug).shownSeries.sort()).toEqual(['datafusion:lance', 'duckdb:lance']); - applyGroupMacro(slug, 'format', 'lance'); + expect(getGroupSnapshot(slug).shownFormats).toEqual(['lance']); expect(getGroupSnapshot(slug).shownSeries).toEqual([]); - expect(getGroupSnapshot(slug).hiddenSeries.sort()).toEqual([ - 'datafusion:lance', - 'duckdb:lance', - ]); + expect(visibleSeries(slug)).toEqual(['datafusion:lance', 'duckdb:lance']); + applyGroupMacro(slug, 'format', 'lance'); + expect(getGroupSnapshot(slug).shownFormats).toEqual([]); + expect(getGroupSnapshot(slug).hiddenFormats).toEqual(['lance']); + expect(visibleSeries(slug)).toEqual([]); }); - it('clears the series filter via the "*" chip without touching Y', () => { - const slug = 'group-clear'; - toggleGroupSeries(slug, 'a'); - setGroupY(slug, 'log'); - clearGroupSeriesFilter(slug); - expect(getGroupSnapshot(slug).hiddenSeries).toEqual([]); - expect(getGroupSnapshot(slug).shownSeries).toEqual([]); - expect(getGroupSnapshot(slug).groupY).toBe('log'); + // The Format row keeps a format it turned on over the global filter as a + // format, so it also shows that format's series in charts that load later. + it('shows a format turned on over the global filter in charts that load later', () => { + const slug = 'group-global-macro-later'; + const universe = { ...UNIVERSE, formats: [...UNIVERSE.formats, 'lance'] }; + initGlobalFilter(universe, null, null); + noteGroupSeries(slug, { 'datafusion:lance': { engine: 'datafusion', format: 'lance' } }); + applyGroupMacro(slug, 'format', 'lance'); + noteGroupSeries(slug, { 'duckdb:lance': { engine: 'duckdb', format: 'lance' } }); + expect(visibleSeries(slug)).toEqual(['datafusion:lance', 'duckdb:lance']); + + clearGroupFilterRow(slug, 'format'); + expect(getGroupSnapshot(slug).shownFormats).toEqual([]); + expect(visibleSeries(slug)).toEqual([]); + }); + + // A chip reads its own row, so the Format row cannot turn an Engine chip off, + // even for an engine whose every series has the format it hides. + it("keeps an engine's chip on when the Format row hides all its series", () => { + const slug = 'group-chip-own-row'; + noteGroupSeries(slug, { + 'datafusion:parquet': { engine: 'datafusion', format: 'parquet' }, + 'duckdb:parquet': { engine: 'duckdb', format: 'parquet' }, + 'duckdb:vortex': { engine: 'duckdb', format: 'vortex' }, + }); + applyGroupMacro(slug, 'format', 'parquet'); + expect(groupMacroIsActive(getGroupSnapshot(slug), 'engine', 'datafusion')).toBe(true); + + applyGroupMacro(slug, 'engine', 'datafusion'); + expect(groupMacroIsActive(getGroupSnapshot(slug), 'engine', 'datafusion')).toBe(false); + expect(getGroupSnapshot(slug).hiddenEngines).toEqual(['datafusion']); + + clearGroupFilterRow(slug, 'format'); + expect(visibleSeries(slug)).toEqual(['duckdb:parquet', 'duckdb:vortex']); + }); + + // The Engine, Format and Series rows of a group's filter are independent: + // each row's "all" chip turns its own row back on and leaves the others. + describe("each row's all chip", () => { + /** A group with datafusion, Parquet, and duckdb:vortex turned off, one per row. */ + function filtered(slug: string): void { + noteGroupSeries(slug, FOUR_SERIES); + applyGroupMacro(slug, 'engine', 'datafusion'); + applyGroupMacro(slug, 'format', 'parquet'); + toggleGroupSeries(slug, 'duckdb:vortex'); + expect(visibleSeries(slug)).toEqual([]); + } + + it('in the Engine row shows the engines and keeps the format and series choices', () => { + const slug = 'group-all-engine'; + filtered(slug); + clearGroupFilterRow(slug, 'engine'); + expect(visibleSeries(slug)).toEqual(['datafusion:vortex']); + expect(getGroupSnapshot(slug).hiddenFormats).toEqual(['parquet']); + expect(getGroupSnapshot(slug).hiddenSeries).toEqual(['duckdb:vortex']); + }); + + it('in the Format row shows the formats and keeps the engine and series choices', () => { + const slug = 'group-all-format'; + filtered(slug); + clearGroupFilterRow(slug, 'format'); + expect(visibleSeries(slug)).toEqual(['duckdb:parquet']); + expect(getGroupSnapshot(slug).hiddenEngines).toEqual(['datafusion']); + }); + + it('in the Series row clears the series choices and keeps the engine and format ones', () => { + const slug = 'group-all-series'; + filtered(slug); + clearGroupFilterRow(slug, 'series'); + expect(visibleSeries(slug)).toEqual(['duckdb:vortex']); + expect(getGroupSnapshot(slug).hiddenEngines).toEqual(['datafusion']); + expect(getGroupSnapshot(slug).hiddenFormats).toEqual(['parquet']); + }); + + it('leaves Y alone', () => { + const slug = 'group-all-y'; + toggleGroupSeries(slug, 'a'); + setGroupY(slug, 'log'); + clearGroupFilterRow(slug, 'series'); + expect(getGroupSnapshot(slug).hiddenSeries).toEqual([]); + expect(getGroupSnapshot(slug).groupY).toBe('log'); + }); + }); + + // A permalink carries the rows' choices, so an engine choice from a link + // also hides the series of a chart that loads afterwards. + it('hides the series of later charts by a seeded engine choice', () => { + const slug = 'group-seeded-engine'; + initGroupFilter(slug, { ...NO_GROUP_FILTER_CHOICES, hiddenEngines: ['datafusion'] }); + noteGroupSeries(slug, FOUR_SERIES); + expect(visibleSeries(slug)).toEqual(['duckdb:parquet', 'duckdb:vortex']); }); it('resets the filter and Y override but keeps known series', () => { const slug = 'group-reset'; - noteGroupSeries(slug, { s1: {} }); + noteGroupSeries(slug, { s1: { engine: 'duckdb', format: 'vortex' } }); toggleGroupSeries(slug, 's1'); + applyGroupMacro(slug, 'engine', 'duckdb'); + applyGroupMacro(slug, 'format', 'vortex'); setGroupY(slug, 'log'); resetGroup(slug); const snap = getGroupSnapshot(slug); expect(snap.hiddenSeries).toEqual([]); expect(snap.shownSeries).toEqual([]); + expect(snap.hiddenEngines).toEqual([]); + expect(snap.hiddenFormats).toEqual([]); expect(snap.groupY).toBeNull(); expect(Object.keys(snap.knownSeries)).toEqual(['s1']); }); diff --git a/src/bench/lib/chart-store/chart-unit.ts b/src/bench/lib/chart-store/chart-unit.ts new file mode 100644 index 00000000..3e6597a0 --- /dev/null +++ b/src/bench/lib/chart-store/chart-unit.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +// --------------------------------------------------------------------------- +// The unit each chart's y-axis is drawn in, per chart slug. +// --------------------------------------------------------------------------- + +const units = new Map(); +const listeners = new Map void>>(); + +/** + * Record the unit a chart's y-axis is now drawn in (see + * [`displayUnitLabel`]). The chart sets it when it first draws and again when + * a wider window changes the unit, so the chart page's meta line names the + * axis it sits above. + */ +export function setChartUnitLabel(slug: string, label: string): void { + if (units.get(slug) === label) { + return; + } + units.set(slug, label); + for (const cb of listeners.get(slug) ?? []) { + cb(); + } +} + +/** The unit a chart last recorded, or `undefined` before it draws. */ +export function getChartUnitLabel(slug: string): string | undefined { + return units.get(slug); +} + +/** Subscribe to one chart's unit changes; returns the unsubscribe function. */ +export function subscribeChartUnitLabel(slug: string, cb: () => void): () => void { + let set = listeners.get(slug); + if (set === undefined) { + set = new Set(); + listeners.set(slug, set); + } + set.add(cb); + return () => { + set.delete(cb); + }; +} diff --git a/src/bench/lib/chart-store/group-store.ts b/src/bench/lib/chart-store/group-store.ts index 846683b4..1e49e061 100644 --- a/src/bench/lib/chart-store/group-store.ts +++ b/src/bench/lib/chart-store/group-store.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import { seriesPassesFilter, seriesPassesGroupFilter } from '@/bench/lib/chart-format'; +import type { GroupFilterChoices } from '@/bench/lib/group-filter'; import type { SeriesTag } from '@/bench/lib/queries'; import { getGlobalFilterSnapshot, type GlobalFilterSnapshot } from './global-filter'; @@ -10,12 +11,30 @@ import { getGlobalFilterSnapshot, type GlobalFilterSnapshot } from './global-fil // Per-group stores (ported from chart-init.js section 17's section-node state). // --------------------------------------------------------------------------- -/** One group's override state plus the series labels its charts have surfaced. */ +/** + * One group's override state plus the series labels its charts have surfaced. + * + * The group's filter dropdown has three rows, and each keeps its own choices, + * so each row's "all" chip clears only its own: the Engine row's in + * `hiddenEngines` and `shownEngines`, the Format row's in `hiddenFormats` and + * `shownFormats`, and the Series row's in `hiddenSeries` and `shownSeries`. A + * series choice wins over an engine or format choice, which wins over the + * global filter. A hidden engine or format wins over a shown one. + */ export interface GroupSnapshot { - /** Dataset labels the user has toggled off via the group's filter dropdown. */ + /** Dataset labels the user has toggled off in the Series row. */ hiddenSeries: string[]; - /** Dataset labels explicitly restored over a global engine/format filter. */ + /** Dataset labels explicitly restored over an engine, format or global + * filter. */ shownSeries: string[]; + /** Engines the user has turned off in the Engine row. */ + hiddenEngines: string[]; + /** Formats the user has turned off in the Format row. */ + hiddenFormats: string[]; + /** Engines the user has turned on in the Engine row over the global filter. */ + shownEngines: string[]; + /** Formats the user has turned on in the Format row over the global filter. */ + shownFormats: string[]; /** The group-level Y override; `null` means "no override; defer to charts". */ groupY: 'linear' | 'log' | null; /** Series labels (with engine/format tags) surfaced by hydrated charts. */ @@ -32,6 +51,10 @@ export interface GroupSnapshot { const EMPTY_GROUP: GroupSnapshot = { hiddenSeries: [], shownSeries: [], + hiddenEngines: [], + hiddenFormats: [], + shownEngines: [], + shownFormats: [], groupY: null, knownSeries: {}, emptyCharts: [], @@ -76,24 +99,37 @@ export function getGroupSnapshot(slug: string): GroupSnapshot { return groupStore(slug).snapshot; } -/** Seed one group's local series overrides from the current page URL. */ -export function initGroupFilter( - slug: string, - hiddenSeries: readonly string[], - shownSeries: readonly string[] = [], -): void { +function sameList(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +/** Seed one group's filter rows from the current page URL. */ +export function initGroupFilter(slug: string, choices: GroupFilterChoices): void { const prev = groupStore(slug).snapshot; - const nextShown = [...new Set(shownSeries)]; - const nextHidden = [...new Set(hiddenSeries)].filter((label) => !nextShown.includes(label)); - if ( - prev.hiddenSeries.length === nextHidden.length && - prev.hiddenSeries.every((label, index) => label === nextHidden[index]) && - prev.shownSeries.length === nextShown.length && - prev.shownSeries.every((label, index) => label === nextShown[index]) - ) { + const shownSeries = [...new Set(choices.shownSeries)]; + const hiddenSeries = [...new Set(choices.hiddenSeries)].filter( + (label) => !shownSeries.includes(label), + ); + const shownEngines = [...new Set(choices.shownEngines)]; + const shownFormats = [...new Set(choices.shownFormats)]; + const hiddenEngines = [...new Set(choices.hiddenEngines)].filter( + (value) => !shownEngines.includes(value), + ); + const hiddenFormats = [...new Set(choices.hiddenFormats)].filter( + (value) => !shownFormats.includes(value), + ); + const next = { + hiddenSeries, + shownSeries, + hiddenEngines, + hiddenFormats, + shownEngines, + shownFormats, + }; + if ((Object.keys(next) as (keyof typeof next)[]).every((key) => sameList(prev[key], next[key]))) { return; } - setGroupSnapshot(slug, { ...prev, hiddenSeries: nextHidden, shownSeries: nextShown }); + setGroupSnapshot(slug, { ...prev, ...next }); } /** The shared empty snapshot, for islands that have no enclosing group. */ @@ -101,18 +137,90 @@ export function emptyGroupSnapshot(): GroupSnapshot { return EMPTY_GROUP; } -/** Effective visibility after the group override falls back to global state. */ +/** A series' engine and format, as a chart dataset or the group store holds them. */ +type Tag = { engine?: string | undefined; format?: string | undefined } | undefined; + +/** Whether the group's Engine or Format row hides a series with this tag. */ +function groupRowsHide(snapshot: GroupSnapshot, tag: Tag): boolean { + return ( + (tag?.engine !== undefined && snapshot.hiddenEngines.includes(tag.engine)) || + (tag?.format !== undefined && snapshot.hiddenFormats.includes(tag.format)) + ); +} + +/** Whether the group's Engine or Format row shows a series with this tag over + * the global filter. */ +function groupRowsShow(snapshot: GroupSnapshot, tag: Tag): boolean { + return ( + (tag?.engine !== undefined && snapshot.shownEngines.includes(tag.engine)) || + (tag?.format !== undefined && snapshot.shownFormats.includes(tag.format)) + ); +} + +/** + * A series' visibility before the Series row's choices: hidden when the + * group's Engine or Format row hides its tag, shown when one of them shows + * it, and otherwise as the global filter has it. + */ +export function groupDefaultVisible( + snapshot: GroupSnapshot, + tag: Tag, + global: GlobalFilterSnapshot = getGlobalFilterSnapshot(), +): boolean { + return ( + !groupRowsHide(snapshot, tag) && + (groupRowsShow(snapshot, tag) || seriesPassesFilter(tag, global.active, global.universe)) + ); +} + +/** Effective visibility: the Series row's choice, else the group's other rows + * and the global filter. */ export function groupSeriesIsVisible( snapshot: GroupSnapshot, label: string, global: GlobalFilterSnapshot = getGlobalFilterSnapshot(), ): boolean { - const defaultVisible = seriesPassesFilter( - snapshot.knownSeries[label], - global.active, - global.universe, + return seriesPassesGroupFilter( + snapshot, + label, + groupDefaultVisible(snapshot, snapshot.knownSeries[label], global), + ); +} + +/** The known series whose `dim` tag is `value`. */ +function seriesMatching( + snapshot: GroupSnapshot, + dim: 'engine' | 'format', + value: string, +): string[] { + return Object.keys(snapshot.knownSeries).filter( + (label) => snapshot.knownSeries[label]?.[dim] === value, + ); +} + +/** + * Whether an Engine or Format chip is on: its row does not hide it, and at + * least one series it covers shows when the other row hides nothing. It reads + * its own row only, so turning a format off leaves every engine chip on, even + * an engine whose every series has that format. + */ +export function groupMacroIsActive( + snapshot: GroupSnapshot, + dim: 'engine' | 'format', + value: string, + global: GlobalFilterSnapshot = getGlobalFilterSnapshot(), +): boolean { + const hidden = dim === 'engine' ? snapshot.hiddenEngines : snapshot.hiddenFormats; + if (hidden.includes(value)) { + return false; + } + const ownRow: GroupSnapshot = + dim === 'engine' + ? { ...snapshot, hiddenFormats: [], shownFormats: [] } + : { ...snapshot, hiddenEngines: [], shownEngines: [] }; + return seriesMatching(snapshot, dim, value).some((label) => + groupSeriesIsVisible(ownRow, label, global), ); - return seriesPassesGroupFilter(snapshot, label, defaultVisible); } function setSeriesVisibility( @@ -126,14 +234,11 @@ function setSeriesVisibility( if (visible) { shownSeries = [ ...shownSeries, - ...labels.filter((label) => { - const defaultVisible = seriesPassesFilter( - snapshot.knownSeries[label], - getGlobalFilterSnapshot().active, - getGlobalFilterSnapshot().universe, - ); - return !defaultVisible && !shownSeries.includes(label); - }), + ...labels.filter( + (label) => + !groupDefaultVisible(snapshot, snapshot.knownSeries[label]) && + !shownSeries.includes(label), + ), ]; } else { hiddenSeries = [...hiddenSeries, ...labels.filter((label) => !hiddenSeries.includes(label))]; @@ -149,28 +254,58 @@ export function toggleGroupSeries(slug: string, label: string): void { } /** - * Apply an engine/format macro click: find every known series whose tag - * matches. If every match is currently visible, hide them all; otherwise (any - * match already hidden) show them all, so the macro chip toggles between "all - * matching visible" and "all matching hidden". + * Apply an Engine or Format chip click. A chip that is on (see + * [`groupMacroIsActive`]) turns off: its row hides the engine or format. A + * chip that is off turns on: its row stops hiding it, and shows it over the + * global filter when the global filter hides a series it covers. The row keeps + * the engine or format itself, so the choice also applies to the series of + * charts that load later. Either way the click replaces the Series row's + * choices for the series the chip covers. A chip that covers no known series + * is inert. */ export function applyGroupMacro(slug: string, dim: 'engine' | 'format', value: string): void { const prev = groupStore(slug).snapshot; - const matching = Object.keys(prev.knownSeries).filter( - (label) => prev.knownSeries[label]?.[dim] === value, - ); + const matching = seriesMatching(prev, dim, value); if (matching.length === 0) { return; } - const allVisible = matching.every((label) => groupSeriesIsVisible(prev, label)); - const overrides = setSeriesVisibility(prev, matching, !allVisible); - setGroupSnapshot(slug, { ...prev, ...overrides }); + const hiddenKey = dim === 'engine' ? 'hiddenEngines' : 'hiddenFormats'; + const shownKey = dim === 'engine' ? 'shownEngines' : 'shownFormats'; + const turnOff = groupMacroIsActive(prev, dim, value); + const covered = new Set(matching); + const without = (list: string[]) => list.filter((v) => v !== value); + const global = getGlobalFilterSnapshot(); + const globalHidesOne = matching.some( + (label) => !seriesPassesFilter(prev.knownSeries[label], global.active, global.universe), + ); + setGroupSnapshot(slug, { + ...prev, + hiddenSeries: prev.hiddenSeries.filter((label) => !covered.has(label)), + shownSeries: prev.shownSeries.filter((label) => !covered.has(label)), + [hiddenKey]: turnOff ? [...without(prev[hiddenKey]), value] : without(prev[hiddenKey]), + [shownKey]: + !turnOff && globalHidesOne ? [...without(prev[shownKey]), value] : without(prev[shownKey]), + }); } -/** Clear the group's series filter (the `'*'` reset chip). */ -export function clearGroupSeriesFilter(slug: string): void { +/** + * Clear one row of the group's filter (that row's `'*'` "all" chip): the + * Engine row's engines, the Format row's formats, or the Series row's series + * choices. The other rows keep theirs, and the global filter still applies. + */ +export function clearGroupFilterRow(slug: string, row: 'engine' | 'format' | 'series'): void { const prev = groupStore(slug).snapshot; - setGroupSnapshot(slug, { ...prev, hiddenSeries: [], shownSeries: [] }); + switch (row) { + case 'engine': + setGroupSnapshot(slug, { ...prev, hiddenEngines: [], shownEngines: [] }); + return; + case 'format': + setGroupSnapshot(slug, { ...prev, hiddenFormats: [], shownFormats: [] }); + return; + case 'series': + setGroupSnapshot(slug, { ...prev, hiddenSeries: [], shownSeries: [] }); + return; + } } /** Set the group-level Y override broadcast to non-sticky charts. */ @@ -179,7 +314,7 @@ export function setGroupY(slug: string, y: 'linear' | 'log'): void { setGroupSnapshot(slug, { ...prev, groupY: y }); } -/** Reset the group: empty series filter, no Y override, and empty charts back +/** Reset the group: an empty filter in every row, no Y override, and empty charts back * to hidden (the default). Per-card legend overrides and per-card sticky Y * choices are intentionally NOT cleared, matching the v3 reset semantics. */ export function resetGroup(slug: string): void { @@ -188,6 +323,10 @@ export function resetGroup(slug: string): void { ...prev, hiddenSeries: [], shownSeries: [], + hiddenEngines: [], + hiddenFormats: [], + shownEngines: [], + shownFormats: [], groupY: null, showEmptyCharts: false, }); diff --git a/src/bench/lib/chart-store/index.ts b/src/bench/lib/chart-store/index.ts index 6c0c2193..0cc0b1f1 100644 --- a/src/bench/lib/chart-store/index.ts +++ b/src/bench/lib/chart-store/index.ts @@ -21,5 +21,6 @@ export * from './queues'; export * from './bundles'; +export * from './chart-unit'; export * from './global-filter'; export * from './group-store'; diff --git a/src/bench/lib/group-bodies.test.ts b/src/bench/lib/group-bodies.test.ts index 88b3cfa8..cfe0ef2a 100644 --- a/src/bench/lib/group-bodies.test.ts +++ b/src/bench/lib/group-bodies.test.ts @@ -12,6 +12,7 @@ import { resetGroupBodies, seedGroupFilterOnce, } from './group-bodies'; +import { NO_GROUP_FILTER_CHOICES } from '@/bench/lib/group-filter'; /** Drain every pending microtask (and promise chain) before asserting. */ function flush(): Promise { @@ -123,15 +124,15 @@ describe('groupBodiesReady', () => { describe('seedGroupFilterOnce', () => { beforeEach(() => { resetGroupBodies(); - initGroupFilter('g', [], []); + initGroupFilter('g', NO_GROUP_FILTER_CHOICES); }); it("seeds a group's filter once per page load", () => { - seedGroupFilterOnce('g', ['a'], []); + seedGroupFilterOnce('g', { ...NO_GROUP_FILTER_CHOICES, hiddenSeries: ['a'] }); expect(getGroupSnapshot('g').hiddenSeries).toEqual(['a']); toggleGroupSeries('g', 'a'); - seedGroupFilterOnce('g', ['a'], []); + seedGroupFilterOnce('g', { ...NO_GROUP_FILTER_CHOICES, hiddenSeries: ['a'] }); expect(getGroupSnapshot('g').hiddenSeries).toEqual([]); }); }); diff --git a/src/bench/lib/group-bodies.ts b/src/bench/lib/group-bodies.ts index f83ef792..049534bd 100644 --- a/src/bench/lib/group-bodies.ts +++ b/src/bench/lib/group-bodies.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import { initGroupFilter } from '@/bench/lib/chart-store'; +import type { GroupFilterChoices } from '@/bench/lib/group-filter'; /** * Page-load bookkeeping for the landing page's group bodies ([`GroupBody`]). @@ -79,20 +80,16 @@ export function groupBodiesReady(groupSlugs: readonly string[]): GroupBodiesRead } /** - * Seed one group's series overrides from the page URL ([`initGroupFilter`]), + * Seed one group's filter rows from the page URL ([`initGroupFilter`]), * the first time per group per page load. A body that closes and reopens, or * remounts, must not overwrite what the user has changed since. */ -export function seedGroupFilterOnce( - groupSlug: string, - hiddenSeries: readonly string[], - shownSeries: readonly string[], -): void { +export function seedGroupFilterOnce(groupSlug: string, choices: GroupFilterChoices): void { if (seededGroups.has(groupSlug)) { return; } seededGroups.add(groupSlug); - initGroupFilter(groupSlug, hiddenSeries, shownSeries); + initGroupFilter(groupSlug, choices); } /** Forget every note, wait and seed. TEST-ONLY: production never resets within diff --git a/src/bench/lib/group-filter.test.ts b/src/bench/lib/group-filter.test.ts index 8fcd1ec2..556243db 100644 --- a/src/bench/lib/group-filter.test.ts +++ b/src/bench/lib/group-filter.test.ts @@ -17,6 +17,30 @@ describe('parseGroupFilter', () => { groupAnchor: 'random-access', hiddenSeries: ['a', 'b'], shownSeries: ['lance'], + hiddenEngines: [], + hiddenFormats: [], + shownEngines: [], + shownFormats: [], + }); + }); + + it('parses the Engine and Format rows', () => { + expect( + parseGroupFilter({ + group: 'tpc-h-nvme-sf-1', + 'hide-engine': ['datafusion', 'datafusion'], + 'hide-format': ['parquet', 'lance'], + 'show-format': 'lance', + 'show-engine': 'duckdb', + }), + ).toEqual({ + groupAnchor: 'tpc-h-nvme-sf-1', + hiddenSeries: [], + shownSeries: [], + hiddenEngines: ['datafusion'], + hiddenFormats: ['parquet'], + shownEngines: ['duckdb'], + shownFormats: ['lance'], }); }); @@ -25,6 +49,10 @@ describe('parseGroupFilter', () => { groupAnchor: 'random-access', hiddenSeries: [], shownSeries: ['lance'], + hiddenEngines: [], + hiddenFormats: [], + shownEngines: [], + shownFormats: [], }); }); diff --git a/src/bench/lib/group-filter.ts b/src/bench/lib/group-filter.ts index 001fd203..d927ae1b 100644 --- a/src/bench/lib/group-filter.ts +++ b/src/bench/lib/group-filter.ts @@ -10,13 +10,57 @@ export const HIDDEN_SERIES_PARAM = 'hide'; /** Repeated query parameter containing one explicitly visible series label. */ export const SHOWN_SERIES_PARAM = 'show'; -export interface GroupFilter { +/** Repeated query parameter containing one engine the group's Engine row hides. */ +export const HIDDEN_ENGINE_PARAM = 'hide-engine'; + +/** Repeated query parameter containing one format the group's Format row hides. */ +export const HIDDEN_FORMAT_PARAM = 'hide-format'; + +/** Repeated query parameter containing one engine the group's Engine row shows + * over the global filter. */ +export const SHOWN_ENGINE_PARAM = 'show-engine'; + +/** Repeated query parameter containing one format the group's Format row shows + * over the global filter. */ +export const SHOWN_FORMAT_PARAM = 'show-format'; + +/** + * One group's filter choices: for each row of its filter dropdown, what it + * hides and what it shows over the global filter. A permalink carries the + * Engine and Format rows' choices as engines and formats, not as the series + * they cover now, so they also apply to the series of charts that load later. + */ +export interface GroupFilterChoices { + hiddenSeries: readonly string[]; + shownSeries: readonly string[]; + hiddenEngines: readonly string[]; + hiddenFormats: readonly string[]; + shownEngines: readonly string[]; + shownFormats: readonly string[]; +} + +interface GroupFilter extends GroupFilterChoices { groupAnchor: string; - hiddenSeries: string[]; - shownSeries: string[]; } -/** Parse one group-local series filter from the landing page query. */ +/** No choices in any row. */ +export const NO_GROUP_FILTER_CHOICES: GroupFilterChoices = { + hiddenSeries: [], + shownSeries: [], + hiddenEngines: [], + hiddenFormats: [], + shownEngines: [], + shownFormats: [], +}; + +/** The non-empty values of one repeated query parameter, without repeats. */ +function repeated(params: Record, name: string): string[] { + const raw = params[name]; + const values = raw === undefined ? [] : Array.isArray(raw) ? raw : [raw]; + return [...new Set(values.filter((value) => value.length > 0))]; +} + +/** Parse one group-local filter from the landing page query. */ export function parseGroupFilter( params: Record, ): GroupFilter | null { @@ -26,16 +70,19 @@ export function parseGroupFilter( return null; } - const rawHidden = params[HIDDEN_SERIES_PARAM]; - const hidden = rawHidden === undefined ? [] : Array.isArray(rawHidden) ? rawHidden : [rawHidden]; - const rawShown = params[SHOWN_SERIES_PARAM]; - const shown = rawShown === undefined ? [] : Array.isArray(rawShown) ? rawShown : [rawShown]; - const shownSeries = [...new Set(shown.filter((value) => value.length > 0))]; + // A value both hidden and shown is shown, as a series is. + const shownSeries = repeated(params, SHOWN_SERIES_PARAM); + const shownEngines = repeated(params, SHOWN_ENGINE_PARAM); + const shownFormats = repeated(params, SHOWN_FORMAT_PARAM); + const hidden = (name: string, shown: string[]) => + repeated(params, name).filter((value) => !shown.includes(value)); return { groupAnchor, - hiddenSeries: [...new Set(hidden.filter((value) => value.length > 0))].filter( - (value) => !shownSeries.includes(value), - ), + hiddenSeries: hidden(HIDDEN_SERIES_PARAM, shownSeries), shownSeries, + hiddenEngines: hidden(HIDDEN_ENGINE_PARAM, shownEngines), + hiddenFormats: hidden(HIDDEN_FORMAT_PARAM, shownFormats), + shownEngines, + shownFormats, }; } diff --git a/src/bench/lib/site-files.ts b/src/bench/lib/site-files.ts index 36880f8a..cccbd22f 100644 --- a/src/bench/lib/site-files.ts +++ b/src/bench/lib/site-files.ts @@ -15,6 +15,7 @@ import { groupAnchor, groupAnchors } from '@/bench/lib/anchor'; import { groupDocUrl } from '@/bench/lib/benchmark-docs'; +import { higherIsBetter } from '@/bench/lib/chart-format'; import { formatCompressionSizeRatio, formatThroughput, formatTimeNs } from '@/bench/lib/format'; import { groupHeadline, headlineText } from '@/bench/lib/headline'; import { METHODOLOGY, SCORES } from '@/bench/lib/methodology'; @@ -80,7 +81,7 @@ export function chartDescription(chart: ChartResponse, kind: ChartKind): string const value = values.findLast((v): v is number => v !== null && Number.isFinite(v)); return value === undefined ? [] : [{ name, value }]; }); - const sign = chart.unit_kind === 'throughput_mb_s' ? -1 : 1; + const sign = higherIsBetter(chart.unit_kind) ? -1 : 1; latest.sort((a, b) => sign * (a.value - b.value)); const commit = chart.commits.at(-1); const values = latest