From 4a6feb23de66205bdf907353093d62b3ef95474b Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sat, 18 Apr 2026 16:44:00 -0700 Subject: [PATCH 1/5] Clean up streaming category ordering --- src/components/charts/ordinal/BarChart.tsx | 2 +- .../charts/ordinal/DotPlot.test.tsx | 4 +- src/components/charts/ordinal/DotPlot.tsx | 9 +- .../charts/ordinal/GroupedBarChart.tsx | 2 +- .../LikertChart.streaming-order.test.tsx | 115 +++++++++++++++++ src/components/charts/ordinal/LikertChart.tsx | 6 +- .../charts/ordinal/StackedBarChart.tsx | 2 +- src/components/charts/shared/hooks.ts | 10 +- src/components/stream/DataSourceAdapter.ts | 21 ++++ .../stream/OrdinalPipelineStore.test.ts | 117 ++++++++++++++++++ src/components/stream/OrdinalPipelineStore.ts | 32 ++++- .../stream/StreamOrdinalFrame.test.tsx | 40 ++++++ src/components/stream/StreamOrdinalFrame.tsx | 16 ++- src/components/stream/ordinalTypes.ts | 16 ++- src/components/stream/types.ts | 9 ++ 15 files changed, 376 insertions(+), 25 deletions(-) create mode 100644 src/components/charts/ordinal/LikertChart.streaming-order.test.tsx diff --git a/src/components/charts/ordinal/BarChart.tsx b/src/components/charts/ordinal/BarChart.tsx index cf1e35ef7..847a3bc2d 100644 --- a/src/components/charts/ordinal/BarChart.tsx +++ b/src/components/charts/ordinal/BarChart.tsx @@ -29,7 +29,7 @@ export interface BarChartProps = Record string colorBy?: ChartAccessor colorScheme?: string | string[] - sort?: boolean | "asc" | "desc" | ((a: Record, b: Record) => number) + sort?: boolean | "asc" | "desc" | "auto" | ((a: Record, b: Record) => number) barPadding?: number /** Rounded top corner radius in pixels. Only the end away from the baseline is rounded. */ roundedTop?: number diff --git a/src/components/charts/ordinal/DotPlot.test.tsx b/src/components/charts/ordinal/DotPlot.test.tsx index e278d4eca..4dcd290e2 100644 --- a/src/components/charts/ordinal/DotPlot.test.tsx +++ b/src/components/charts/ordinal/DotPlot.test.tsx @@ -86,13 +86,13 @@ describe("DotPlot", () => { expect(lastOrdinalFrameProps.projection).toBe("vertical") }) - it("defaults sort to true", () => { + it("defaults sort to 'auto' (insertion order when streaming, value-desc when static)", () => { render( ) - expect(lastOrdinalFrameProps.oSort).toBe(true) + expect(lastOrdinalFrameProps.oSort).toBe("auto") }) it("forwards sort=false as oSort", () => { diff --git a/src/components/charts/ordinal/DotPlot.tsx b/src/components/charts/ordinal/DotPlot.tsx index fbb6d961a..ac3f51a2d 100644 --- a/src/components/charts/ordinal/DotPlot.tsx +++ b/src/components/charts/ordinal/DotPlot.tsx @@ -26,7 +26,12 @@ export interface DotPlotProps = Record string colorBy?: ChartAccessor colorScheme?: string | string[] - sort?: boolean | "asc" | "desc" | ((a: Record, b: Record) => number) + /** Category ordering. `true` / `undefined` → value-desc. `"auto"` preserves + * insertion order while streaming, then switches to value-desc on static + * data — the recommended default when using the push API so categories + * don't jump around as values fluctuate. `"asc"` / `"desc"` / comparator + * for explicit control. `false` for insertion order regardless of source. */ + sort?: boolean | "asc" | "desc" | "auto" | ((a: Record, b: Record) => number) dotRadius?: number categoryPadding?: number enableHover?: boolean @@ -73,7 +78,7 @@ export const DotPlot = forwardRef(function DotPlot = Recor colorBy?: ChartAccessor colorScheme?: string | string[] /** Category sort order. Default: false (data insertion order). "asc"/"desc" sorts by total grouped value. Custom comparators receive category keys. */ - sort?: boolean | "asc" | "desc" | ((a: string, b: string) => number) + sort?: boolean | "asc" | "desc" | "auto" | ((a: string, b: string) => number) barPadding?: number /** Rounded corner radius on bar ends (away from baseline). */ roundedTop?: number diff --git a/src/components/charts/ordinal/LikertChart.streaming-order.test.tsx b/src/components/charts/ordinal/LikertChart.streaming-order.test.tsx new file mode 100644 index 000000000..57bd3ab4d --- /dev/null +++ b/src/components/charts/ordinal/LikertChart.streaming-order.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import React from "react" +import { render, act } from "@testing-library/react" +import { LikertChart } from "./LikertChart" +import { TooltipProvider } from "../../store/TooltipStore" +import { setupCanvasMock } from "../../../test-utils/canvasMock" + +// Mock ResizeObserver for jsdom +if (typeof globalThis.ResizeObserver === "undefined") { + (globalThis as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } +} + +// This file tests LikertChart end-to-end with the real StreamOrdinalFrame +// (unlike LikertChart.test.tsx, which mocks the frame to inspect props). +// Purpose: lock down the streaming category-ordering contract that +// regressed when `replace()` cleared the category Set on every +// aggregation. Each push should leave the question order stable even +// when the per-question value ordering shifts dramatically. + +describe("LikertChart streaming category order", () => { + let cleanup: () => void + beforeEach(() => { cleanup = setupCanvasMock() }) + afterEach(() => { cleanup() }) + + const levels = ["Strongly Disagree", "Disagree", "Neutral", "Agree", "Strongly Agree"] + + it("preserves question order across pushes where the value-ranked order changes", async () => { + const ref = React.createRef() + render( + + + + ) + + // Seed with responses across three questions in insertion order Q1 → Q2 → Q3. + await act(async () => { + ref.current.pushMany([ + { question: "Q1", score: 3 }, + { question: "Q1", score: 4 }, + { question: "Q2", score: 2 }, + { question: "Q3", score: 5 }, + ]) + }) + await new Promise(r => queueMicrotask(() => r(null))) // let adapter microtask flush + + const firstDomain = ref.current.getScales()?.o.domain() + expect(firstDomain).toEqual(["Q1", "Q2", "Q3"]) + + // Push a batch that flips the per-question total-response counts — + // Q3 now has by far the most responses, Q1 the fewest. Under the + // old value-desc ordering this would put Q3 first and Q1 last. + // preserveCategoryOrder should keep Q1 → Q2 → Q3. + await act(async () => { + ref.current.pushMany([ + { question: "Q3", score: 5 }, + { question: "Q3", score: 4 }, + { question: "Q3", score: 5 }, + { question: "Q3", score: 4 }, + { question: "Q2", score: 3 }, + { question: "Q2", score: 3 }, + ]) + }) + await new Promise(r => queueMicrotask(() => r(null))) + + const secondDomain = ref.current.getScales()?.o.domain() + expect(secondDomain).toEqual(["Q1", "Q2", "Q3"]) + }) + + it("appends a newly-arriving question at the end, not at the top by value", async () => { + const ref = React.createRef() + render( + + + + ) + + await act(async () => { + ref.current.pushMany([ + { question: "Q1", score: 3 }, + { question: "Q2", score: 4 }, + ]) + }) + await new Promise(r => queueMicrotask(() => r(null))) + expect(ref.current.getScales()?.o.domain()).toEqual(["Q1", "Q2"]) + + // Q3 arrives late but gets a huge number of responses. It should + // still land at the end of the axis (FIFO), not at the front. + await act(async () => { + ref.current.pushMany([ + { question: "Q3", score: 5 }, + { question: "Q3", score: 5 }, + { question: "Q3", score: 5 }, + { question: "Q3", score: 5 }, + { question: "Q3", score: 5 }, + ]) + }) + await new Promise(r => queueMicrotask(() => r(null))) + + expect(ref.current.getScales()?.o.domain()).toEqual(["Q1", "Q2", "Q3"]) + }) +}) diff --git a/src/components/charts/ordinal/LikertChart.tsx b/src/components/charts/ordinal/LikertChart.tsx index b32748aa6..8b417d2b3 100644 --- a/src/components/charts/ordinal/LikertChart.tsx +++ b/src/components/charts/ordinal/LikertChart.tsx @@ -231,7 +231,11 @@ export const LikertChart = forwardRef(function LikertChart frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + // Exposed for debuggability and test verification of streaming + // category order. The ordinal scale's domain is the source of truth + // for how categories appear in the rendered output. + getScales: () => frameRef.current?.getScales() ?? null }), [wrappedPush, wrappedPushMany, streaming.resetCategories, accumulatorRef]) // ── Chart setup ────────────────────────────────────────────────────── diff --git a/src/components/charts/ordinal/StackedBarChart.tsx b/src/components/charts/ordinal/StackedBarChart.tsx index 879c65e21..163d00438 100644 --- a/src/components/charts/ordinal/StackedBarChart.tsx +++ b/src/components/charts/ordinal/StackedBarChart.tsx @@ -30,7 +30,7 @@ export interface StackedBarChartProps = Recor colorScheme?: string | string[] normalize?: boolean /** Category sort order. Default: false (data insertion order). "asc"/"desc" sorts by total stacked value. Custom comparators receive category keys. */ - sort?: boolean | "asc" | "desc" | ((a: string, b: string) => number) + sort?: boolean | "asc" | "desc" | "auto" | ((a: string, b: string) => number) barPadding?: number /** Rounded top corner radius. Only the topmost stacked segment gets rounded. */ roundedTop?: number diff --git a/src/components/charts/shared/hooks.ts b/src/components/charts/shared/hooks.ts index 308c5a2b0..986206c6f 100644 --- a/src/components/charts/shared/hooks.ts +++ b/src/components/charts/shared/hooks.ts @@ -138,14 +138,20 @@ export function useColorScale( /** * Hook to sort data by a value accessor. * Used by BarChart and DotPlot. + * + * `"auto"` is a pass-through here: the frame-level `resolveCategories` + * decides whether to preserve insertion order (streaming) or sort by + * value (static). The row-level ordering of `data` in the HOC doesn't + * actually drive the visual category order — that comes from the store — + * so "auto" at the HOC level simply declines to sort. */ export function useSortedData( data: Array>, - sort: boolean | "asc" | "desc" | ((a: Record, b: Record) => number), + sort: boolean | "asc" | "desc" | "auto" | ((a: Record, b: Record) => number), valueAccessor: Accessor ): Array> { return useMemo(() => { - if (!sort) return data + if (!sort || sort === "auto") return data const copy = [...data] if (typeof sort === "function") return copy.sort(sort) const getValue = resolveAccessor(valueAccessor) diff --git a/src/components/stream/DataSourceAdapter.ts b/src/components/stream/DataSourceAdapter.ts index e6d62c5fd..b425299fc 100644 --- a/src/components/stream/DataSourceAdapter.ts +++ b/src/components/stream/DataSourceAdapter.ts @@ -109,6 +109,27 @@ export class DataSourceAdapter> { this.chunkTimer = requestAnimationFrame(scheduleNext) } + /** + * Replace the buffer contents without clearing category insertion-order + * memory. Intended for aggregator HOCs (e.g. LikertChart) that re-derive + * a full dataset from streaming input on every push — the transport is + * wholesale replacement, but the user perceives it as a live stream + * where categories should stay put across updates. + * + * Emits a single synchronous changeset (no progressive chunking): the + * aggregated dataset size is small and bounded by the category-count, + * not the stream length, so chunking buys nothing and would fragment + * the transition animation. + */ + setReplacementData(data: T[]): void { + this.lastBoundedData = data + if (this.chunkTimer) { + cancelAnimationFrame(this.chunkTimer) + this.chunkTimer = 0 + } + this.callback({ inserts: data, bounded: true, preserveCategoryOrder: true }) + } + /** * Flush all buffered push data as a single changeset. * Called automatically via microtask after push()/pushMany(). diff --git a/src/components/stream/OrdinalPipelineStore.test.ts b/src/components/stream/OrdinalPipelineStore.test.ts index 96b162e6f..7ddea93a5 100644 --- a/src/components/stream/OrdinalPipelineStore.test.ts +++ b/src/components/stream/OrdinalPipelineStore.test.ts @@ -380,6 +380,123 @@ describe("OrdinalPipelineStore", () => { }) }) + // ── preserveCategoryOrder (aggregator-HOC path) ───────────────────── + + describe("bounded ingest with preserveCategoryOrder", () => { + // Regression: aggregator HOCs (LikertChart, future density/bin charts) + // re-derive their full dataset from streaming input on every push. + // They route through bounded ingest for atomic replacement, but the + // user perceives a live stream and expects categories to stay in + // place. Without `preserveCategoryOrder`, each replacement clears + // the category memory and re-sorts — producing visible shuffling. + + it("preserves category insertion order across multiple replacements", () => { + const store = new OrdinalPipelineStore(makeConfig()) + store.ingest({ + inserts: [ + { category: "Q1", value: 10 }, + { category: "Q2", value: 20 }, + { category: "Q3", value: 15 }, + ], + bounded: true, + preserveCategoryOrder: true, + }) + store.computeScene({ width: 400, height: 300 }) + expect(Object.keys(store.columns)).toEqual(["Q1", "Q2", "Q3"]) + + // Replacement: Q2 now has the largest value. With ordinary bounded + // ingest this would re-sort to ["Q2", "Q3", "Q1"] (value-desc), but + // the preserve flag should keep FIFO order. + store.ingest({ + inserts: [ + { category: "Q1", value: 5 }, + { category: "Q2", value: 99 }, + { category: "Q3", value: 40 }, + ], + bounded: true, + preserveCategoryOrder: true, + }) + store.computeScene({ width: 400, height: 300 }) + expect(Object.keys(store.columns)).toEqual(["Q1", "Q2", "Q3"]) + }) + + it("appends newly-arriving categories at the end without shuffling existing ones", () => { + const store = new OrdinalPipelineStore(makeConfig()) + store.ingest({ + inserts: [{ category: "A", value: 10 }, { category: "B", value: 20 }], + bounded: true, + preserveCategoryOrder: true, + }) + store.computeScene({ width: 400, height: 300 }) + expect(Object.keys(store.columns)).toEqual(["A", "B"]) + + // New category C arrives with a larger value than A/B — should + // still appear last (insertion order), not first (value-desc). + store.ingest({ + inserts: [ + { category: "A", value: 10 }, + { category: "B", value: 20 }, + { category: "C", value: 999 }, + ], + bounded: true, + preserveCategoryOrder: true, + }) + store.computeScene({ width: 400, height: 300 }) + expect(Object.keys(store.columns)).toEqual(["A", "B", "C"]) + }) + + it("flips the store into streaming mode so sort='auto' preserves order", () => { + // `sort: "auto"` means "insertion-order-when-streaming, value-desc-when- + // static". preserveCategoryOrder ingest should flip the streaming flag + // so auto → preserve. + const store = new OrdinalPipelineStore(makeConfig({ oSort: "auto" })) + store.ingest({ + inserts: [ + { category: "Small", value: 1 }, + { category: "Big", value: 100 }, + { category: "Medium", value: 50 }, + ], + bounded: true, + preserveCategoryOrder: true, + }) + store.computeScene({ width: 400, height: 300 }) + // Insertion order, not value-desc + expect(Object.keys(store.columns)).toEqual(["Small", "Big", "Medium"]) + }) + }) + + // ── sort: "auto" ──────────────────────────────────────────────────── + + describe("sort='auto' mode", () => { + it("preserves insertion order for streaming push data", () => { + const store = new OrdinalPipelineStore(makeConfig({ oSort: "auto" })) + store.ingest({ + inserts: [ + { category: "Gamma", value: 5 }, + { category: "Alpha", value: 100 }, + { category: "Beta", value: 50 }, + ], + bounded: false, + }) + store.computeScene({ width: 400, height: 300 }) + expect(Object.keys(store.columns)).toEqual(["Gamma", "Alpha", "Beta"]) + }) + + it("falls through to value-descending for static bounded data", () => { + const store = new OrdinalPipelineStore(makeConfig({ oSort: "auto" })) + store.ingest({ + inserts: [ + { category: "Small", value: 1 }, + { category: "Big", value: 100 }, + { category: "Medium", value: 50 }, + ], + bounded: true, + }) + store.computeScene({ width: 400, height: 300 }) + expect(Object.keys(store.columns)).toEqual(["Big", "Medium", "Small"]) + }) + }) + // ── Window/eviction behavior ──────────────────────────────────────── describe("window/eviction behavior", () => { diff --git a/src/components/stream/OrdinalPipelineStore.ts b/src/components/stream/OrdinalPipelineStore.ts index 444ed403f..c330e5321 100644 --- a/src/components/stream/OrdinalPipelineStore.ts +++ b/src/components/stream/OrdinalPipelineStore.ts @@ -167,7 +167,18 @@ export class OrdinalPipelineStore { if (changeset.bounded) { this.buffer.clear() this.rExtent.clear() - this.categories.clear() + // `preserveCategoryOrder` is the escape hatch for aggregator HOCs + // that re-derive their full dataset from streaming input on every + // push (LikertChart, etc.). Without it, the category insertion + // order resets on every replacement and categories appear to + // shuffle as values fluctuate. The flag also marks the store as + // streaming-sourced so `resolveCategories` takes the preserve + // branch. + if (!changeset.preserveCategoryOrder) { + this.categories.clear() + } else { + this._hasStreamingData = true + } if (this.timestampBuffer) this.timestampBuffer.clear() const targetSize = changeset.totalSize || changeset.inserts.length @@ -390,11 +401,20 @@ export class OrdinalPipelineStore { private resolveCategories(data: Record[]): string[] { const cats = Array.from(this.categories) const sort = this.config.oSort + const isStreaming = this.config.runtimeMode === "streaming" || this._hasStreamingData + + // "auto" means "insertion order when streaming, value-desc when + // static" — the right default for charts where users want value-sort + // on a finished dataset but FIFO stability while data is still + // arriving (DotPlot, LikertChart). Both arms collapse to `undefined` + // because the streaming-preserve branch fires on `undefined && isStreaming` + // and the value-desc fallback fires on `undefined && !isStreaming`. + const effectiveSort: typeof sort = sort === "auto" ? undefined : sort // In streaming mode (explicit runtimeMode or push-API data), preserve // insertion order by default to avoid jarring category shuffling as // values fluctuate in the sliding window - if ((this.config.runtimeMode === "streaming" || this._hasStreamingData) && sort === undefined) { + if (isStreaming && effectiveSort === undefined) { // Filter to only categories with live data in the buffer, but do NOT // delete from the Set — so if a category's data is evicted and later // re-pushed, it retains its original FIFO position (no shuffling) @@ -421,10 +441,10 @@ export class OrdinalPipelineStore { return cats.filter(cat => liveCategories.has(cat)) } - if (sort === false) return cats + if (effectiveSort === false) return cats - if (typeof sort === "function") { - return cats.sort(sort) + if (typeof effectiveSort === "function") { + return cats.sort(effectiveSort) } // Default: sort by total value descending (unless explicitly "asc") @@ -434,7 +454,7 @@ export class OrdinalPipelineStore { sums.set(cat, (sums.get(cat) || 0) + Math.abs(this.getR(d))) } - if (sort === "asc") { + if (effectiveSort === "asc") { return cats.sort((a, b) => (sums.get(a) || 0) - (sums.get(b) || 0)) } diff --git a/src/components/stream/StreamOrdinalFrame.test.tsx b/src/components/stream/StreamOrdinalFrame.test.tsx index c04aace18..9fe5a2c0f 100644 --- a/src/components/stream/StreamOrdinalFrame.test.tsx +++ b/src/components/stream/StreamOrdinalFrame.test.tsx @@ -301,6 +301,46 @@ describe("StreamOrdinalFrame", () => { expect(ref.current!.getData().find((d: any) => d.val === 50)).toBeTruthy() }) + it("replace preserves category insertion order across value-swapping updates", async () => { + // Regression: aggregator-HOC pattern. Rapid replacements where the + // rank-by-value flips between each call must NOT shuffle columns, + // because the user sees it as a live stream. Without the + // preserveCategoryOrder path, each replace would clear the + // category Set and re-sort value-desc, producing visible jumps. + // + // Using default oAccessor="category" / rAccessor="value" since this + // test exercises the non-runtimeMode="streaming" path that + // aggregator HOCs actually use — LikertChart doesn't set that + // prop; it routes through replace() which flips the streaming + // state internally via `preserveCategoryOrder`. + const ref = React.createRef() + render( + + ) + await act(async () => { + ref.current!.replace([ + { category: "Q1", value: 10 }, + { category: "Q2", value: 20 }, + { category: "Q3", value: 15 }, + ]) + }) + // First replacement seeds order Q1 → Q2 → Q3. + await act(async () => { + ref.current!.replace([ + { category: "Q1", value: 5 }, + { category: "Q2", value: 99 }, // biggest — would sort first under value-desc + { category: "Q3", value: 40 }, + ]) + }) + const scales = ref.current!.getScales() + // Ordinal scale domain reflects category order used by the scene. + expect(scales?.o.domain()).toEqual(["Q1", "Q2", "Q3"]) + }) + it("clear empties the data buffer", async () => { const ref = React.createRef() render( diff --git a/src/components/stream/StreamOrdinalFrame.tsx b/src/components/stream/StreamOrdinalFrame.tsx index ae5da55a2..a664b867f 100644 --- a/src/components/stream/StreamOrdinalFrame.tsx +++ b/src/components/stream/StreamOrdinalFrame.tsx @@ -471,10 +471,16 @@ const StreamOrdinalFrame = forwardRef[]) => { adapterRef.current?.clearLastData() - adapterRef.current?.setBoundedData(newData) + adapterRef.current?.setReplacementData(newData) }, []) useImperativeHandle(ref, () => ({ diff --git a/src/components/stream/ordinalTypes.ts b/src/components/stream/ordinalTypes.ts index 91f9a96eb..d4cac7796 100644 --- a/src/components/stream/ordinalTypes.ts +++ b/src/components/stream/ordinalTypes.ts @@ -247,8 +247,12 @@ export interface OrdinalPipelineConfig { connectorOpacity?: number showLabels?: boolean - // Sort — comparator receives category names (strings) - oSort?: ((a: any, b: any) => number) | boolean | "asc" | "desc" + // Sort — comparator receives category names (strings). + // "auto" preserves insertion order while streaming and sorts by value + // descending on static data. Pass `"asc"` / `"desc"` / a comparator for + // explicit ordering, `false` / insertion-order always, or `true` / + // undefined for value-desc regardless of source. + oSort?: ((a: any, b: any) => number) | boolean | "asc" | "desc" | "auto" // Connectors connectorAccessor?: string | ((d: any) => string) @@ -332,8 +336,12 @@ export interface StreamOrdinalFrameProps> { oExtent?: string[] extentPadding?: number - // Sort — comparator receives category names (strings) - oSort?: ((a: any, b: any) => number) | boolean | "asc" | "desc" + // Sort — comparator receives category names (strings). + // "auto" preserves insertion order while streaming and sorts by value + // descending on static data. Pass `"asc"` / `"desc"` / a comparator for + // explicit ordering, `false` / insertion-order always, or `true` / + // undefined for value-desc regardless of source. + oSort?: ((a: any, b: any) => number) | boolean | "asc" | "desc" | "auto" // Streaming arrowOfTime?: ArrowOfTime diff --git a/src/components/stream/types.ts b/src/components/stream/types.ts index 731381f95..3348476ab 100644 --- a/src/components/stream/types.ts +++ b/src/components/stream/types.ts @@ -325,6 +325,15 @@ export interface Changeset> { bounded: boolean /** Hint: total dataset size when progressively chunking bounded data */ totalSize?: number + /** When true on a bounded changeset, the store replaces the buffer + * contents but does NOT clear its category insertion-order memory + * and marks itself as having received streaming-sourced data. Used + * by aggregator HOCs (LikertChart, future density/bin charts) that + * re-derive their full dataset from streaming input on every push — + * the user perceives it as a stream even though the transport is + * a wholesale replacement. Without this, re-aggregation would wipe + * the category order and categories would shuffle on every tick. */ + preserveCategoryOrder?: boolean } // ── Scales ───────────────────────────────────────────────────────────── From 1c1067f84cf51cbbe9849ae3439129b4619956fd Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sat, 18 Apr 2026 17:06:54 -0700 Subject: [PATCH 2/5] pr fixes --- src/components/charts/index.ts | 2 +- src/components/charts/ordinal/BarChart.tsx | 3 +- src/components/charts/ordinal/BoxPlot.tsx | 3 +- .../ordinal/DotPlot.streaming-order.test.tsx | 98 +++++++++++++++++++ src/components/charts/ordinal/DotPlot.tsx | 3 +- src/components/charts/ordinal/FunnelChart.tsx | 3 +- src/components/charts/ordinal/Histogram.tsx | 3 +- src/components/charts/ordinal/LikertChart.tsx | 11 ++- .../charts/ordinal/RidgelinePlot.tsx | 3 +- src/components/charts/ordinal/SwarmPlot.tsx | 3 +- src/components/charts/ordinal/ViolinPlot.tsx | 3 +- .../charts/realtime/RealtimeHeatmap.tsx | 3 +- .../charts/realtime/RealtimeHistogram.tsx | 3 +- .../charts/realtime/RealtimeLineChart.tsx | 3 +- .../charts/realtime/RealtimeSwarmChart.tsx | 3 +- .../realtime/RealtimeWaterfallChart.tsx | 3 +- src/components/charts/shared/hooks.test.ts | 11 +++ src/components/charts/xy/AreaChart.tsx | 3 +- src/components/charts/xy/BubbleChart.tsx | 3 +- .../charts/xy/ConnectedScatterplot.tsx | 3 +- src/components/charts/xy/Heatmap.tsx | 3 +- src/components/charts/xy/LineChart.tsx | 3 +- .../charts/xy/MultiAxisLineChart.tsx | 3 +- src/components/charts/xy/QuadrantChart.tsx | 3 +- src/components/charts/xy/Scatterplot.tsx | 3 +- src/components/charts/xy/StackedAreaChart.tsx | 3 +- src/components/realtime/types.ts | 12 +++ src/components/stream/DataSourceAdapter.ts | 46 ++++++++- src/components/stream/OrdinalPipelineStore.ts | 7 ++ 29 files changed, 224 insertions(+), 29 deletions(-) create mode 100644 src/components/charts/ordinal/DotPlot.streaming-order.test.tsx diff --git a/src/components/charts/index.ts b/src/components/charts/index.ts index 749a8f34d..edc47c6f3 100644 --- a/src/components/charts/index.ts +++ b/src/components/charts/index.ts @@ -54,7 +54,7 @@ export { StackedBarChart } from "./ordinal/StackedBarChart" export type { StackedBarChartProps } from "./ordinal/StackedBarChart" export { LikertChart } from "./ordinal/LikertChart" -export type { LikertChartProps } from "./ordinal/LikertChart" +export type { LikertChartProps, LikertChartHandle } from "./ordinal/LikertChart" export { SwarmPlot } from "./ordinal/SwarmPlot" export type { SwarmPlotProps } from "./ordinal/SwarmPlot" diff --git a/src/components/charts/ordinal/BarChart.tsx b/src/components/charts/ordinal/BarChart.tsx index 847a3bc2d..3359334b3 100644 --- a/src/components/charts/ordinal/BarChart.tsx +++ b/src/components/charts/ordinal/BarChart.tsx @@ -75,7 +75,8 @@ export const BarChart = forwardRef(function BarChart frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const { diff --git a/src/components/charts/ordinal/BoxPlot.tsx b/src/components/charts/ordinal/BoxPlot.tsx index 6be8b273c..26b81e0ac 100644 --- a/src/components/charts/ordinal/BoxPlot.tsx +++ b/src/components/charts/ordinal/BoxPlot.tsx @@ -66,7 +66,8 @@ export const BoxPlot = forwardRef(function BoxPlot frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const { diff --git a/src/components/charts/ordinal/DotPlot.streaming-order.test.tsx b/src/components/charts/ordinal/DotPlot.streaming-order.test.tsx new file mode 100644 index 000000000..0fd09bee4 --- /dev/null +++ b/src/components/charts/ordinal/DotPlot.streaming-order.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import React from "react" +import { render, act } from "@testing-library/react" +import { DotPlot } from "./DotPlot" +import { TooltipProvider } from "../../store/TooltipStore" +import { setupCanvasMock } from "../../../test-utils/canvasMock" + +// Mock ResizeObserver for jsdom +if (typeof globalThis.ResizeObserver === "undefined") { + (globalThis as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } +} + +// End-to-end test (real StreamOrdinalFrame) for DotPlot's default +// `sort="auto"` behavior: insertion order while streaming, value-desc +// when static. The sibling DotPlot.test.tsx mocks the frame and can +// only inspect props — it can't verify the scene actually renders in +// the expected order. + +describe("DotPlot streaming category order", () => { + let cleanup: () => void + beforeEach(() => { cleanup = setupCanvasMock() }) + afterEach(() => { cleanup() }) + + it("default sort='auto' preserves insertion order under streaming", async () => { + const ref = React.createRef() + render( + + + + ) + + // Push categories in the order C → A → B — if the store value-sorted + // (the pre-"auto" default of `sort=true`), the domain would come + // out ["B", "A", "C"]. "auto" under streaming should preserve FIFO. + await act(async () => { + ref.current.push({ category: "C", value: 10 }) + }) + await act(async () => { + ref.current.push({ category: "A", value: 30 }) + }) + await act(async () => { + ref.current.push({ category: "B", value: 20 }) + }) + + const domain = ref.current.getScales()?.o.domain() + expect(domain).toEqual(["C", "A", "B"]) + }) + + it("default sort='auto' falls through to value-desc on static data", () => { + const ref = React.createRef() + render( + + + + ) + // No push — purely static data. sort="auto" should behave like the + // old default of sort=true: value-descending. + const domain = ref.current.getScales()?.o.domain() + expect(domain).toEqual(["Big", "Medium", "Small"]) + }) + + it("explicit sort='desc' wins over auto even during streaming", async () => { + // Regression guard: users who opt into value-sort during streaming + // (e.g. "I want this to always value-sort, shuffle be damned") must + // get that behavior. "auto" applies only when sort is unset or + // explicitly "auto". + const ref = React.createRef() + render( + + + + ) + await act(async () => { ref.current.push({ category: "Low", value: 10 }) }) + await act(async () => { ref.current.push({ category: "High", value: 100 }) }) + await act(async () => { ref.current.push({ category: "Mid", value: 50 }) }) + + const domain = ref.current.getScales()?.o.domain() + expect(domain).toEqual(["High", "Mid", "Low"]) + }) +}) diff --git a/src/components/charts/ordinal/DotPlot.tsx b/src/components/charts/ordinal/DotPlot.tsx index ac3f51a2d..9aa2fb82f 100644 --- a/src/components/charts/ordinal/DotPlot.tsx +++ b/src/components/charts/ordinal/DotPlot.tsx @@ -71,7 +71,8 @@ export const DotPlot = forwardRef(function DotPlot frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const { diff --git a/src/components/charts/ordinal/FunnelChart.tsx b/src/components/charts/ordinal/FunnelChart.tsx index 775f95c24..9f03ead46 100644 --- a/src/components/charts/ordinal/FunnelChart.tsx +++ b/src/components/charts/ordinal/FunnelChart.tsx @@ -84,7 +84,8 @@ export const FunnelChart = forwardRef(function FunnelChart frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const { diff --git a/src/components/charts/ordinal/Histogram.tsx b/src/components/charts/ordinal/Histogram.tsx index 86ea75d7d..c1a75909a 100644 --- a/src/components/charts/ordinal/Histogram.tsx +++ b/src/components/charts/ordinal/Histogram.tsx @@ -71,7 +71,8 @@ export const Histogram = forwardRef(function Histogram frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const { diff --git a/src/components/charts/ordinal/LikertChart.tsx b/src/components/charts/ordinal/LikertChart.tsx index 8b417d2b3..77d8ef0ff 100644 --- a/src/components/charts/ordinal/LikertChart.tsx +++ b/src/components/charts/ordinal/LikertChart.tsx @@ -115,11 +115,20 @@ export interface LikertChartProps = Record> } +// Ref handle for LikertChart — extends the shared RealtimeFrameHandle +// with `getScales()` so TS consumers can introspect the rendered +// category order (e.g. `ref.current?.getScales()?.o.domain()`) without +// casting. Useful for debug overlays, streaming-order assertions, and +// anyone wiring custom interactions on top of the chart. +export interface LikertChartHandle extends RealtimeFrameHandle { + getScales(): ReturnType> +} + // ── Component ──────────────────────────────────────────────────────────── export const LikertChart = forwardRef(function LikertChart = Record>( props: LikertChartProps, - ref: React.Ref + ref: React.Ref ) { const resolved = useChartMode(props.mode, { width: props.width, diff --git a/src/components/charts/ordinal/RidgelinePlot.tsx b/src/components/charts/ordinal/RidgelinePlot.tsx index 6ec652493..28d435901 100644 --- a/src/components/charts/ordinal/RidgelinePlot.tsx +++ b/src/components/charts/ordinal/RidgelinePlot.tsx @@ -73,7 +73,8 @@ export const RidgelinePlot = forwardRef(function RidgelinePlot frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const { diff --git a/src/components/charts/ordinal/SwarmPlot.tsx b/src/components/charts/ordinal/SwarmPlot.tsx index 1c1b5af1c..eda39e413 100644 --- a/src/components/charts/ordinal/SwarmPlot.tsx +++ b/src/components/charts/ordinal/SwarmPlot.tsx @@ -75,7 +75,8 @@ export const SwarmPlot = forwardRef(function SwarmPlot frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const { diff --git a/src/components/charts/ordinal/ViolinPlot.tsx b/src/components/charts/ordinal/ViolinPlot.tsx index f0eb3e8ba..f1124a7f9 100644 --- a/src/components/charts/ordinal/ViolinPlot.tsx +++ b/src/components/charts/ordinal/ViolinPlot.tsx @@ -74,7 +74,8 @@ export const ViolinPlot = forwardRef(function ViolinPlot frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const { diff --git a/src/components/charts/realtime/RealtimeHeatmap.tsx b/src/components/charts/realtime/RealtimeHeatmap.tsx index 0508795f0..d0f309ff3 100644 --- a/src/components/charts/realtime/RealtimeHeatmap.tsx +++ b/src/components/charts/realtime/RealtimeHeatmap.tsx @@ -204,7 +204,8 @@ export const RealtimeHeatmap = forwardRef( remove: (id) => frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) // ── Loading / empty states (computed early, returned after all hooks) ─── diff --git a/src/components/charts/realtime/RealtimeHistogram.tsx b/src/components/charts/realtime/RealtimeHistogram.tsx index 838e56d0f..820b35097 100644 --- a/src/components/charts/realtime/RealtimeHistogram.tsx +++ b/src/components/charts/realtime/RealtimeHistogram.tsx @@ -318,7 +318,8 @@ export const RealtimeTemporalHistogram = forwardRef( remove: (id) => frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) // ── Loading / empty states (computed early, returned after all hooks) ─── diff --git a/src/components/charts/realtime/RealtimeLineChart.tsx b/src/components/charts/realtime/RealtimeLineChart.tsx index 53c351c96..6876d9cd3 100644 --- a/src/components/charts/realtime/RealtimeLineChart.tsx +++ b/src/components/charts/realtime/RealtimeLineChart.tsx @@ -204,7 +204,8 @@ export const RealtimeLineChart = forwardRef( remove: (id) => frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) // ── Loading / empty states (computed early, returned after all hooks) ─── diff --git a/src/components/charts/realtime/RealtimeSwarmChart.tsx b/src/components/charts/realtime/RealtimeSwarmChart.tsx index 0e8a84ca9..b063956d3 100644 --- a/src/components/charts/realtime/RealtimeSwarmChart.tsx +++ b/src/components/charts/realtime/RealtimeSwarmChart.tsx @@ -202,7 +202,8 @@ export const RealtimeSwarmChart = forwardRef( remove: (id) => frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) // ── Loading / empty states (computed early, returned after all hooks) ─── diff --git a/src/components/charts/realtime/RealtimeWaterfallChart.tsx b/src/components/charts/realtime/RealtimeWaterfallChart.tsx index f286eb948..ba37e9984 100644 --- a/src/components/charts/realtime/RealtimeWaterfallChart.tsx +++ b/src/components/charts/realtime/RealtimeWaterfallChart.tsx @@ -199,7 +199,8 @@ export const RealtimeWaterfallChart = forwardRef( remove: (id) => frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) // ── Loading / empty states (computed early, returned after all hooks) ─── diff --git a/src/components/charts/shared/hooks.test.ts b/src/components/charts/shared/hooks.test.ts index 4fc46ddad..c87606190 100644 --- a/src/components/charts/shared/hooks.test.ts +++ b/src/components/charts/shared/hooks.test.ts @@ -172,6 +172,17 @@ describe("useSortedData", () => { renderHook(() => useSortedData(data, "asc", "value")) expect(data).toEqual(original) }) + + // `"auto"` is a pass-through at the HOC level: the frame's + // resolveCategories decides between insertion-order (streaming) and + // value-desc (static) based on the store's streaming state. HOCs + // shouldn't pre-sort their row array under "auto", or they'd fight + // the store's decision. + it("returns the same array reference when sort is 'auto'", () => { + const { result } = renderHook(() => useSortedData(data, "auto", "value")) + expect(result.current).toBe(data) + expect(result.current.map((d) => d.name)).toEqual(["C", "A", "B"]) + }) }) // ── useChartSelection ───────────────────────────────────────────────────── diff --git a/src/components/charts/xy/AreaChart.tsx b/src/components/charts/xy/AreaChart.tsx index 890211898..265f11961 100644 --- a/src/components/charts/xy/AreaChart.tsx +++ b/src/components/charts/xy/AreaChart.tsx @@ -220,7 +220,8 @@ export const AreaChart = forwardRef(function AreaChart frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const resolved = useChartMode(props.mode, { diff --git a/src/components/charts/xy/BubbleChart.tsx b/src/components/charts/xy/BubbleChart.tsx index 2b63763ef..b78744390 100644 --- a/src/components/charts/xy/BubbleChart.tsx +++ b/src/components/charts/xy/BubbleChart.tsx @@ -333,7 +333,8 @@ export const BubbleChart = forwardRef(function BubbleChart v + 1) frameRef.current?.clear() }, - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null }), [wrappedPush, wrappedPushMany, streaming.resetCategories]) // ── Selection hooks (always called, conditional logic inside) ────────── diff --git a/src/components/charts/xy/ConnectedScatterplot.tsx b/src/components/charts/xy/ConnectedScatterplot.tsx index eccc47ac0..7eb826b98 100644 --- a/src/components/charts/xy/ConnectedScatterplot.tsx +++ b/src/components/charts/xy/ConnectedScatterplot.tsx @@ -85,7 +85,8 @@ export const ConnectedScatterplot = forwardRef(function ConnectedScatterplot frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const resolved = useChartMode(props.mode, { diff --git a/src/components/charts/xy/Heatmap.tsx b/src/components/charts/xy/Heatmap.tsx index ab9ceacae..be73e08bd 100644 --- a/src/components/charts/xy/Heatmap.tsx +++ b/src/components/charts/xy/Heatmap.tsx @@ -217,7 +217,8 @@ export const Heatmap = forwardRef(function Heatmap frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const resolved = useChartMode(props.mode, { diff --git a/src/components/charts/xy/LineChart.tsx b/src/components/charts/xy/LineChart.tsx index c9d1bbe2a..8654099d7 100644 --- a/src/components/charts/xy/LineChart.tsx +++ b/src/components/charts/xy/LineChart.tsx @@ -308,7 +308,8 @@ export const LineChart = forwardRef( remove: (id) => frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const resolved = useChartMode(props.mode, { diff --git a/src/components/charts/xy/MultiAxisLineChart.tsx b/src/components/charts/xy/MultiAxisLineChart.tsx index fe36ed425..4da1d9f34 100644 --- a/src/components/charts/xy/MultiAxisLineChart.tsx +++ b/src/components/charts/xy/MultiAxisLineChart.tsx @@ -167,7 +167,8 @@ export const MultiAxisLineChart = forwardRef(function MultiAxisLineChart frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const resolved = useChartMode(props.mode, { diff --git a/src/components/charts/xy/QuadrantChart.tsx b/src/components/charts/xy/QuadrantChart.tsx index 5188cc104..cd7ae6b12 100644 --- a/src/components/charts/xy/QuadrantChart.tsx +++ b/src/components/charts/xy/QuadrantChart.tsx @@ -139,7 +139,8 @@ export const QuadrantChart = forwardRef(function QuadrantChart frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const resolved = useChartMode(props.mode, { diff --git a/src/components/charts/xy/Scatterplot.tsx b/src/components/charts/xy/Scatterplot.tsx index 35c1ca6ff..089b3881e 100644 --- a/src/components/charts/xy/Scatterplot.tsx +++ b/src/components/charts/xy/Scatterplot.tsx @@ -82,7 +82,8 @@ export const Scatterplot = forwardRef(function Scatterplot frameRef.current?.remove(id) ?? [], update: (id, updater) => frameRef.current?.update(id, updater) ?? [], clear: () => frameRef.current?.clear(), - getData: () => frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null })) const resolved = useChartMode(props.mode, { diff --git a/src/components/charts/xy/StackedAreaChart.tsx b/src/components/charts/xy/StackedAreaChart.tsx index 28ae9576e..0b0d7d4f0 100644 --- a/src/components/charts/xy/StackedAreaChart.tsx +++ b/src/components/charts/xy/StackedAreaChart.tsx @@ -286,7 +286,8 @@ export const StackedAreaChart = forwardRef(function StackedAreaChart frameRef.current?.getData() ?? [] + getData: () => frameRef.current?.getData() ?? [], + getScales: () => frameRef.current?.getScales() ?? null }), [wrappedPush, wrappedPushMany, streaming.resetCategories]) // ── Selection hooks (always called, conditional logic inside) ────────── diff --git a/src/components/realtime/types.ts b/src/components/realtime/types.ts index 725b1b1dd..f01c6a230 100644 --- a/src/components/realtime/types.ts +++ b/src/components/realtime/types.ts @@ -169,6 +169,18 @@ export interface RealtimeFrameHandle { update(id: string | string[], updater: (d: Record) => Record): Record[] clear(): void getData(): Record[] + /** Returns the frame's resolved scales, or null if unavailable. + * + * The concrete scales object differs by frame type — XY charts + * expose `{ x, y }`, ordinal charts expose `{ o, r, projection }`, + * network/geo don't have a meaningful scale concept and may not + * implement this method at all. + * + * Typed as `unknown` so the shared handle stays compatible across + * chart families. HOCs that want a narrower return type should + * export a chart-specific handle (e.g. `LikertChartHandle`) that + * extends this interface and narrows `getScales()`. */ + getScales?(): unknown | null } export interface RealtimeScales { diff --git a/src/components/stream/DataSourceAdapter.ts b/src/components/stream/DataSourceAdapter.ts index b425299fc..1f218277b 100644 --- a/src/components/stream/DataSourceAdapter.ts +++ b/src/components/stream/DataSourceAdapter.ts @@ -116,10 +116,14 @@ export class DataSourceAdapter> { * wholesale replacement, but the user perceives it as a live stream * where categories should stay put across updates. * - * Emits a single synchronous changeset (no progressive chunking): the - * aggregated dataset size is small and bounded by the category-count, - * not the stream length, so chunking buys nothing and would fragment - * the transition animation. + * Small datasets (≤ chunkThreshold) emit a single synchronous + * changeset — the common aggregator case. Larger datasets fall + * through to progressive chunking so an unexpectedly-huge replacement + * doesn't block the main thread. Only the first chunk carries + * `preserveCategoryOrder: true` (it's the one that resets the + * buffer and seeds the category Set); subsequent chunks are plain + * append-only streaming changesets, which preserve order anyway via + * the streaming-mode branch. */ setReplacementData(data: T[]): void { this.lastBoundedData = data @@ -127,7 +131,39 @@ export class DataSourceAdapter> { cancelAnimationFrame(this.chunkTimer) this.chunkTimer = 0 } - this.callback({ inserts: data, bounded: true, preserveCategoryOrder: true }) + // Drop any pending push microtask state so replacement is atomic — + // otherwise a buffered push()/pushMany() from just before this call + // can flush after the replacement and append stale points onto the + // fresh dataset. + this.pushBuffer = [] + this.flushScheduled = false + + if (data.length <= this.chunkThreshold) { + this.callback({ inserts: data, bounded: true, preserveCategoryOrder: true }) + return + } + + this.callback({ + inserts: data.slice(0, this.chunkSize), + bounded: true, + preserveCategoryOrder: true, + totalSize: data.length, + }) + + let offset = this.chunkSize + const scheduleNext = () => { + if (offset >= data.length) return + if (data !== this.lastBoundedData) return + const end = Math.min(offset + this.chunkSize, data.length) + this.callback({ inserts: data.slice(offset, end), bounded: false }) + offset = end + if (offset < data.length) { + this.chunkTimer = requestAnimationFrame(scheduleNext) + } else { + this.chunkTimer = 0 + } + } + this.chunkTimer = requestAnimationFrame(scheduleNext) } /** diff --git a/src/components/stream/OrdinalPipelineStore.ts b/src/components/stream/OrdinalPipelineStore.ts index c330e5321..5eb3ccd01 100644 --- a/src/components/stream/OrdinalPipelineStore.ts +++ b/src/components/stream/OrdinalPipelineStore.ts @@ -166,7 +166,14 @@ export class OrdinalPipelineStore { if (changeset.bounded) { this.buffer.clear() + // Clear all per-accessor extents — in multiAxis (rAccessor is an + // array), `rExtents` holds distinct IncrementalExtent instances + // that aren't aliased by `rExtent`, so only clearing `rExtent` + // leaves stale min/max on the other axes. In the single-accessor + // case `rExtents[0]` *is* `rExtent`, so the two-line sequence is + // still correct (the second clear is a no-op). this.rExtent.clear() + for (const ext of this.rExtents) ext.clear() // `preserveCategoryOrder` is the escape hatch for aggregator HOCs // that re-derive their full dataset from streaming input on every // push (LikertChart, etc.). Without it, the category insertion From 2e8120817ed3757b6249f54e46cbde288140e4da Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sat, 18 Apr 2026 20:40:22 -0700 Subject: [PATCH 3/5] pr feedback fixes --- src/components/charts/ordinal/BarChart.tsx | 8 +++++- src/components/charts/ordinal/DotPlot.tsx | 15 ++++++----- .../charts/ordinal/GroupedBarChart.tsx | 2 +- src/components/charts/ordinal/LikertChart.tsx | 2 +- .../charts/ordinal/StackedBarChart.tsx | 2 +- src/components/charts/shared/hooks.test.ts | 12 ++++++--- src/components/charts/shared/hooks.ts | 18 +++++++------ src/components/stream/ordinalTypes.ts | 26 +++++++++++++------ 8 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/components/charts/ordinal/BarChart.tsx b/src/components/charts/ordinal/BarChart.tsx index 3359334b3..bc4b16436 100644 --- a/src/components/charts/ordinal/BarChart.tsx +++ b/src/components/charts/ordinal/BarChart.tsx @@ -29,7 +29,13 @@ export interface BarChartProps = Record string colorBy?: ChartAccessor colorScheme?: string | string[] - sort?: boolean | "asc" | "desc" | "auto" | ((a: Record, b: Record) => number) + /** Category ordering. `false` (default) = insertion order. `"asc"` / + * `"desc"` sorts by total value. `"auto"` preserves insertion order + * while streaming and falls through to value-desc on static data. + * `true` = value-desc regardless of source. Function comparators + * receive category name strings (not row objects) and run against + * the category list on the axis. */ + sort?: boolean | "asc" | "desc" | "auto" | ((a: string, b: string) => number) barPadding?: number /** Rounded top corner radius in pixels. Only the end away from the baseline is rounded. */ roundedTop?: number diff --git a/src/components/charts/ordinal/DotPlot.tsx b/src/components/charts/ordinal/DotPlot.tsx index 9aa2fb82f..cdc94d91b 100644 --- a/src/components/charts/ordinal/DotPlot.tsx +++ b/src/components/charts/ordinal/DotPlot.tsx @@ -26,12 +26,15 @@ export interface DotPlotProps = Record string colorBy?: ChartAccessor colorScheme?: string | string[] - /** Category ordering. `true` / `undefined` → value-desc. `"auto"` preserves - * insertion order while streaming, then switches to value-desc on static - * data — the recommended default when using the push API so categories - * don't jump around as values fluctuate. `"asc"` / `"desc"` / comparator - * for explicit control. `false` for insertion order regardless of source. */ - sort?: boolean | "asc" | "desc" | "auto" | ((a: Record, b: Record) => number) + /** Category ordering. Default (`undefined`) resolves to `"auto"`, which + * preserves insertion order while streaming and falls through to + * value-desc on static data — the recommended choice when using the + * push API so categories don't jump around as values fluctuate. + * `true` forces value-desc regardless of source. `"asc"` / `"desc"` + * sorts by total value. `false` for insertion order regardless of + * source. Function comparators receive category name strings (not + * row objects) and run against the category list on the axis. */ + sort?: boolean | "asc" | "desc" | "auto" | ((a: string, b: string) => number) dotRadius?: number categoryPadding?: number enableHover?: boolean diff --git a/src/components/charts/ordinal/GroupedBarChart.tsx b/src/components/charts/ordinal/GroupedBarChart.tsx index 3a1a16522..08e3ceef4 100644 --- a/src/components/charts/ordinal/GroupedBarChart.tsx +++ b/src/components/charts/ordinal/GroupedBarChart.tsx @@ -28,7 +28,7 @@ export interface GroupedBarChartProps = Recor valueFormat?: (d: number | string) => string colorBy?: ChartAccessor colorScheme?: string | string[] - /** Category sort order. Default: false (data insertion order). "asc"/"desc" sorts by total grouped value. Custom comparators receive category keys. */ + /** Category sort order. Default: `false` (data insertion order). `"asc"`/`"desc"` sorts by total grouped value. `"auto"` preserves insertion order while streaming and falls through to value-desc on static data. Custom comparators receive category keys. */ sort?: boolean | "asc" | "desc" | "auto" | ((a: string, b: string) => number) barPadding?: number /** Rounded corner radius on bar ends (away from baseline). */ diff --git a/src/components/charts/ordinal/LikertChart.tsx b/src/components/charts/ordinal/LikertChart.tsx index 77d8ef0ff..6407cba16 100644 --- a/src/components/charts/ordinal/LikertChart.tsx +++ b/src/components/charts/ordinal/LikertChart.tsx @@ -470,7 +470,7 @@ export const LikertChart = forwardRef(function LikertChart = Record>( - props: LikertChartProps & React.RefAttributes + props: LikertChartProps & React.RefAttributes ): React.ReactElement | null displayName?: string } diff --git a/src/components/charts/ordinal/StackedBarChart.tsx b/src/components/charts/ordinal/StackedBarChart.tsx index 163d00438..5b5de4f79 100644 --- a/src/components/charts/ordinal/StackedBarChart.tsx +++ b/src/components/charts/ordinal/StackedBarChart.tsx @@ -29,7 +29,7 @@ export interface StackedBarChartProps = Recor colorBy?: ChartAccessor colorScheme?: string | string[] normalize?: boolean - /** Category sort order. Default: false (data insertion order). "asc"/"desc" sorts by total stacked value. Custom comparators receive category keys. */ + /** Category sort order. Default: `false` (data insertion order). `"asc"`/`"desc"` sorts by total stacked value. `"auto"` preserves insertion order while streaming and falls through to value-desc on static data. Custom comparators receive category keys. */ sort?: boolean | "asc" | "desc" | "auto" | ((a: string, b: string) => number) barPadding?: number /** Rounded top corner radius. Only the topmost stacked segment gets rounded. */ diff --git a/src/components/charts/shared/hooks.test.ts b/src/components/charts/shared/hooks.test.ts index c87606190..6534bddce 100644 --- a/src/components/charts/shared/hooks.test.ts +++ b/src/components/charts/shared/hooks.test.ts @@ -158,13 +158,17 @@ describe("useSortedData", () => { expect(result.current.map((d) => d.name)).toEqual(["C", "B", "A"]) }) - it("uses a custom sort function", () => { - const customSort = (a: Record, b: Record) => - a.name.localeCompare(b.name) + it("returns the same array reference when sort is a function (category comparator)", () => { + // A function `sort` is a category-key comparator forwarded to the + // frame as `oSort`. It does not sort HOC row data, so + // useSortedData leaves the array untouched — mirroring the + // "auto" pass-through. + const customSort = (a: string, b: string) => a.localeCompare(b) const { result } = renderHook(() => useSortedData(data, customSort, "value") ) - expect(result.current.map((d) => d.name)).toEqual(["A", "B", "C"]) + expect(result.current).toBe(data) + expect(result.current.map((d) => d.name)).toEqual(["C", "A", "B"]) }) it("does not mutate the original data array", () => { diff --git a/src/components/charts/shared/hooks.ts b/src/components/charts/shared/hooks.ts index 986206c6f..6eccf5e45 100644 --- a/src/components/charts/shared/hooks.ts +++ b/src/components/charts/shared/hooks.ts @@ -139,21 +139,23 @@ export function useColorScale( * Hook to sort data by a value accessor. * Used by BarChart and DotPlot. * - * `"auto"` is a pass-through here: the frame-level `resolveCategories` - * decides whether to preserve insertion order (streaming) or sort by - * value (static). The row-level ordering of `data` in the HOC doesn't - * actually drive the visual category order — that comes from the store — - * so "auto" at the HOC level simply declines to sort. + * `"auto"` and function comparators are pass-through here. The frame- + * level `resolveCategories` decides the visual category order: + * • `"auto"` → insertion order when streaming, value-desc when static. + * • function → runs as a category-key comparator on the axis list. + * The HOC's row-level `data` order only seeds insertion order via the + * store's category Set; rearranging rows with a category comparator + * makes no sense (it would call the comparator with row objects instead + * of strings), so we decline to sort in both cases. */ export function useSortedData( data: Array>, - sort: boolean | "asc" | "desc" | "auto" | ((a: Record, b: Record) => number), + sort: boolean | "asc" | "desc" | "auto" | ((a: string, b: string) => number), valueAccessor: Accessor ): Array> { return useMemo(() => { - if (!sort || sort === "auto") return data + if (!sort || sort === "auto" || typeof sort === "function") return data const copy = [...data] - if (typeof sort === "function") return copy.sort(sort) const getValue = resolveAccessor(valueAccessor) return sort === "asc" ? copy.sort((a, b) => getValue(a) - getValue(b)) diff --git a/src/components/stream/ordinalTypes.ts b/src/components/stream/ordinalTypes.ts index d4cac7796..3ca1b9e26 100644 --- a/src/components/stream/ordinalTypes.ts +++ b/src/components/stream/ordinalTypes.ts @@ -248,10 +248,15 @@ export interface OrdinalPipelineConfig { showLabels?: boolean // Sort — comparator receives category names (strings). - // "auto" preserves insertion order while streaming and sorts by value - // descending on static data. Pass `"asc"` / `"desc"` / a comparator for - // explicit ordering, `false` / insertion-order always, or `true` / - // undefined for value-desc regardless of source. + // • `"auto"` / `undefined` — preserve insertion order while streaming, + // fall through to value-desc on static data. + // • `"asc"` / `"desc"` — sort by total value, ascending / descending. + // • `true` — legacy alias for value-desc regardless of source (used + // as the default on some HOCs pre-"auto"; new HOCs should prefer + // `"auto"`). + // • `false` — insertion order always. + // • function — custom category comparator; receives the two category + // name strings, returns a negative/positive number for ordering. oSort?: ((a: any, b: any) => number) | boolean | "asc" | "desc" | "auto" // Connectors @@ -337,10 +342,15 @@ export interface StreamOrdinalFrameProps> { extentPadding?: number // Sort — comparator receives category names (strings). - // "auto" preserves insertion order while streaming and sorts by value - // descending on static data. Pass `"asc"` / `"desc"` / a comparator for - // explicit ordering, `false` / insertion-order always, or `true` / - // undefined for value-desc regardless of source. + // • `"auto"` / `undefined` — preserve insertion order while streaming, + // fall through to value-desc on static data. + // • `"asc"` / `"desc"` — sort by total value, ascending / descending. + // • `true` — legacy alias for value-desc regardless of source (used + // as the default on some HOCs pre-"auto"; new HOCs should prefer + // `"auto"`). + // • `false` — insertion order always. + // • function — custom category comparator; receives the two category + // name strings, returns a negative/positive number for ordering. oSort?: ((a: any, b: any) => number) | boolean | "asc" | "desc" | "auto" // Streaming From ceee0e6273e1952eb2f8d2f26cad472db938a3e9 Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sat, 18 Apr 2026 21:01:14 -0700 Subject: [PATCH 4/5] more feedback --- src/components/stream/DataSourceAdapter.ts | 28 +++++++++++++++++--- src/components/stream/StreamOrdinalFrame.tsx | 2 +- src/components/stream/ordinalTypes.ts | 4 +-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/components/stream/DataSourceAdapter.ts b/src/components/stream/DataSourceAdapter.ts index 1f218277b..f6d2496fc 100644 --- a/src/components/stream/DataSourceAdapter.ts +++ b/src/components/stream/DataSourceAdapter.ts @@ -91,9 +91,17 @@ export class DataSourceAdapter> { let offset = this.chunkSize const scheduleNext = () => { - if (offset >= data.length) return + // Clear on every exit path so `chunkTimer === 0 iff no rAF + // scheduled` holds. See matching note in `setReplacementData`. + if (offset >= data.length) { + this.chunkTimer = 0 + return + } // Check that this is still the active dataset - if (data !== this.lastBoundedData) return + if (data !== this.lastBoundedData) { + this.chunkTimer = 0 + return + } const end = Math.min(offset + this.chunkSize, data.length) this.callback({ inserts: data.slice(offset, end), bounded: false }) @@ -152,8 +160,20 @@ export class DataSourceAdapter> { let offset = this.chunkSize const scheduleNext = () => { - if (offset >= data.length) return - if (data !== this.lastBoundedData) return + // Clear the timer on ANY exit path — the adapter-state invariant + // is "chunkTimer is 0 iff no rAF is scheduled". Without these + // explicit resets, an already-complete chunk sequence (or a + // superseded dataset) could leave chunkTimer non-zero and + // `setBoundedData` / `clearLastData` would needlessly call + // `cancelAnimationFrame` on a stale token. + if (offset >= data.length) { + this.chunkTimer = 0 + return + } + if (data !== this.lastBoundedData) { + this.chunkTimer = 0 + return + } const end = Math.min(offset + this.chunkSize, data.length) this.callback({ inserts: data.slice(offset, end), bounded: false }) offset = end diff --git a/src/components/stream/StreamOrdinalFrame.tsx b/src/components/stream/StreamOrdinalFrame.tsx index a664b867f..a79832b45 100644 --- a/src/components/stream/StreamOrdinalFrame.tsx +++ b/src/components/stream/StreamOrdinalFrame.tsx @@ -472,7 +472,7 @@ const StreamOrdinalFrame = forwardRef number) | boolean | "asc" | "desc" | "auto" + oSort?: ((a: string, b: string) => number) | boolean | "asc" | "desc" | "auto" // Connectors connectorAccessor?: string | ((d: any) => string) @@ -351,7 +351,7 @@ export interface StreamOrdinalFrameProps> { // • `false` — insertion order always. // • function — custom category comparator; receives the two category // name strings, returns a negative/positive number for ordering. - oSort?: ((a: any, b: any) => number) | boolean | "asc" | "desc" | "auto" + oSort?: ((a: string, b: string) => number) | boolean | "asc" | "desc" | "auto" // Streaming arrowOfTime?: ArrowOfTime From b759d74ad77c010c4d2d78c7085cb71eaa4c71f6 Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sat, 18 Apr 2026 21:19:13 -0700 Subject: [PATCH 5/5] ordinal pipeline store issue --- .../stream/OrdinalPipelineStore.test.ts | 53 +++++++++++++++++++ src/components/stream/OrdinalPipelineStore.ts | 34 +++++++----- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/components/stream/OrdinalPipelineStore.test.ts b/src/components/stream/OrdinalPipelineStore.test.ts index 7ddea93a5..8eda991e2 100644 --- a/src/components/stream/OrdinalPipelineStore.test.ts +++ b/src/components/stream/OrdinalPipelineStore.test.ts @@ -463,6 +463,59 @@ describe("OrdinalPipelineStore", () => { // Insertion order, not value-desc expect(Object.keys(store.columns)).toEqual(["Small", "Big", "Medium"]) }) + + it("drops stale categories from the axis after a replace that removes them (even with explicit sort)", () => { + // Regression: the preserve-order mechanism retains ghost categories in + // `this.categories` for FIFO stability across re-appearances, but + // every resolveCategories branch must filter against the live data + // set. Without this, `oSort: "desc"` or a comparator would render + // empty ticks for categories whose data was dropped by a replace(). + const store = new OrdinalPipelineStore(makeConfig({ oSort: "desc" })) + store.ingest({ + inserts: [ + { category: "A", value: 10 }, + { category: "B", value: 20 }, + { category: "C", value: 30 }, + ], + bounded: true, + preserveCategoryOrder: true, + }) + store.computeScene({ width: 400, height: 300 }) + expect(Object.keys(store.columns).sort()).toEqual(["A", "B", "C"]) + + // B gets dropped by the replacement. + store.ingest({ + inserts: [ + { category: "A", value: 5 }, + { category: "C", value: 40 }, + ], + bounded: true, + preserveCategoryOrder: true, + }) + store.computeScene({ width: 400, height: 300 }) + expect(Object.keys(store.columns).sort()).toEqual(["A", "C"]) + }) + + it("drops stale categories even when sort is false (insertion-order)", () => { + const store = new OrdinalPipelineStore(makeConfig({ oSort: false })) + store.ingest({ + inserts: [{ category: "A", value: 1 }, { category: "B", value: 2 }], + bounded: true, + preserveCategoryOrder: true, + }) + store.computeScene({ width: 400, height: 300 }) + expect(Object.keys(store.columns)).toEqual(["A", "B"]) + + store.ingest({ + inserts: [{ category: "B", value: 99 }], + bounded: true, + preserveCategoryOrder: true, + }) + store.computeScene({ width: 400, height: 300 }) + // A is evicted from the current dataset; the axis should not render + // a ghost column for it even though A stays in the category Set. + expect(Object.keys(store.columns)).toEqual(["B"]) + }) }) // ── sort: "auto" ──────────────────────────────────────────────────── diff --git a/src/components/stream/OrdinalPipelineStore.ts b/src/components/stream/OrdinalPipelineStore.ts index 5eb3ccd01..af127fbf5 100644 --- a/src/components/stream/OrdinalPipelineStore.ts +++ b/src/components/stream/OrdinalPipelineStore.ts @@ -406,7 +406,6 @@ export class OrdinalPipelineStore { // ── Category resolution ────────────────────────────────────────────── private resolveCategories(data: Record[]): string[] { - const cats = Array.from(this.categories) const sort = this.config.oSort const isStreaming = this.config.runtimeMode === "streaming" || this._hasStreamingData @@ -418,34 +417,45 @@ export class OrdinalPipelineStore { // and the value-desc fallback fires on `undefined && !isStreaming`. const effectiveSort: typeof sort = sort === "auto" ? undefined : sort - // In streaming mode (explicit runtimeMode or push-API data), preserve - // insertion order by default to avoid jarring category shuffling as - // values fluctuate in the sliding window - if (isStreaming && effectiveSort === undefined) { - // Filter to only categories with live data in the buffer, but do NOT - // delete from the Set — so if a category's data is evicted and later - // re-pushed, it retains its original FIFO position (no shuffling) - const liveCategories = new Set() + // Under streaming, `this.categories` is an insertion-ordered memory + // of every category we've ever seen (including ones whose data was + // evicted or dropped by a `replace()`). That's load-bearing for FIFO + // stability across re-appearances, but we don't want axis ticks or + // columns for categories that aren't in the current dataset — every + // branch below should filter against `liveCategories` so explicit + // sorts (`"desc"`, comparator, `false`) don't render ghost columns + // after a replacement drops a category. + let liveCategories: Set | null = null + if (isStreaming) { + liveCategories = new Set() for (const d of data) { liveCategories.add(this.getO(d)) } + } + const cats = liveCategories + ? Array.from(this.categories).filter(cat => liveCategories!.has(cat)) + : Array.from(this.categories) + // In streaming mode (explicit runtimeMode or push-API data), preserve + // insertion order by default to avoid jarring category shuffling as + // values fluctuate in the sliding window + if (isStreaming && effectiveSort === undefined) { // Cap the retained history to prevent unbounded growth in high-cardinality // streams. Prune dead categories from the front (oldest first) when the // Set exceeds 3x the live count, keeping recent evictions for FIFO stability. - const maxRetained = Math.max(50, liveCategories.size * 3) + const maxRetained = Math.max(50, liveCategories!.size * 3) if (this.categories.size > maxRetained) { let toRemove = this.categories.size - maxRetained for (const cat of this.categories) { if (toRemove <= 0) break - if (!liveCategories.has(cat)) { + if (!liveCategories!.has(cat)) { this.categories.delete(cat) toRemove-- } } } - return cats.filter(cat => liveCategories.has(cat)) + return cats } if (effectiveSort === false) return cats