Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/charts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 9 additions & 2 deletions src/components/charts/ordinal/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ export interface BarChartProps<TDatum extends Record<string, any> = Record<strin
valueFormat?: (d: number | string) => string
colorBy?: ChartAccessor<TDatum, string>
colorScheme?: string | string[]
sort?: boolean | "asc" | "desc" | ((a: Record<string, any>, b: Record<string, any>) => 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
Comment thread
emeeks marked this conversation as resolved.
* 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
Expand Down Expand Up @@ -75,7 +81,8 @@ export const BarChart = forwardRef(function BarChart<TDatum extends Record<strin
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 {
Expand Down
3 changes: 2 additions & 1 deletion src/components/charts/ordinal/BoxPlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ export const BoxPlot = forwardRef(function BoxPlot<TDatum extends Record<string,
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 {
Expand Down
98 changes: 98 additions & 0 deletions src/components/charts/ordinal/DotPlot.streaming-order.test.tsx
Original file line number Diff line number Diff line change
@@ -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<any>()
render(
<TooltipProvider>
<DotPlot ref={ref} categoryAccessor="category" valueAccessor="value" />
</TooltipProvider>
)

// 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<any>()
render(
<TooltipProvider>
<DotPlot
ref={ref}
data={[
{ category: "Small", value: 1 },
{ category: "Big", value: 100 },
{ category: "Medium", value: 50 },
]}
categoryAccessor="category"
valueAccessor="value"
/>
</TooltipProvider>
)
// 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<any>()
render(
<TooltipProvider>
<DotPlot
ref={ref}
categoryAccessor="category"
valueAccessor="value"
sort="desc"
/>
</TooltipProvider>
)
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"])
})
})
4 changes: 2 additions & 2 deletions src/components/charts/ordinal/DotPlot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TooltipProvider>
<DotPlot data={sampleData} />
</TooltipProvider>
)
expect(lastOrdinalFrameProps.oSort).toBe(true)
expect(lastOrdinalFrameProps.oSort).toBe("auto")
})

it("forwards sort=false as oSort", () => {
Expand Down
15 changes: 12 additions & 3 deletions src/components/charts/ordinal/DotPlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ export interface DotPlotProps<TDatum extends Record<string, any> = Record<string
valueFormat?: (d: number | string) => string
colorBy?: ChartAccessor<TDatum, string>
colorScheme?: string | string[]
sort?: boolean | "asc" | "desc" | ((a: Record<string, any>, b: Record<string, any>) => 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
Expand Down Expand Up @@ -66,14 +74,15 @@ export const DotPlot = forwardRef(function DotPlot<TDatum extends Record<string,
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 {
data, margin: userMargin, className,
categoryAccessor = "category", valueAccessor = "value",
orientation = "horizontal", valueFormat,
colorBy, colorScheme, sort = true, dotRadius = 5,
colorBy, colorScheme, sort = "auto", dotRadius = 5,
categoryPadding = 10, tooltip, annotations, frameProps = {}, selection, linkedHover,
onObservation, onClick, hoverHighlight, chartId,
loading, emptyContent,
Expand Down
3 changes: 2 additions & 1 deletion src/components/charts/ordinal/FunnelChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ export const FunnelChart = forwardRef(function FunnelChart<TDatum extends Record
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 {
Expand Down
4 changes: 2 additions & 2 deletions src/components/charts/ordinal/GroupedBarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export interface GroupedBarChartProps<TDatum extends Record<string, any> = Recor
valueFormat?: (d: number | string) => string
colorBy?: ChartAccessor<TDatum, string>
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)
/** 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). */
roundedTop?: number
Expand Down
3 changes: 2 additions & 1 deletion src/components/charts/ordinal/Histogram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ export const Histogram = forwardRef(function Histogram<TDatum extends Record<str
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 {
Expand Down
115 changes: 115 additions & 0 deletions src/components/charts/ordinal/LikertChart.streaming-order.test.tsx
Original file line number Diff line number Diff line change
@@ -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<any>()
render(
<TooltipProvider>
<LikertChart
ref={ref}
levels={levels}
valueAccessor="score"
categoryAccessor="question"
/>
</TooltipProvider>
)

// 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<any>()
render(
<TooltipProvider>
<LikertChart
ref={ref}
levels={levels}
valueAccessor="score"
categoryAccessor="question"
/>
</TooltipProvider>
)

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"])
})
})
19 changes: 16 additions & 3 deletions src/components/charts/ordinal/LikertChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,20 @@ export interface LikertChartProps<TDatum extends Record<string, any> = Record<st
frameProps?: Partial<Omit<StreamOrdinalFrameProps, "data" | "size">>
}

// 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<NonNullable<StreamOrdinalFrameHandle["getScales"]>>
}

// ── Component ────────────────────────────────────────────────────────────

export const LikertChart = forwardRef(function LikertChart<TDatum extends Record<string, any> = Record<string, any>>(
props: LikertChartProps<TDatum>,
ref: React.Ref<RealtimeFrameHandle>
ref: React.Ref<LikertChartHandle>
) {
Comment thread
emeeks marked this conversation as resolved.
const resolved = useChartMode(props.mode, {
width: props.width,
Expand Down Expand Up @@ -231,7 +240,11 @@ export const LikertChart = forwardRef(function LikertChart<TDatum extends Record
streaming.resetCategories()
frameRef.current?.clear()
},
getData: () => 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
Comment thread
emeeks marked this conversation as resolved.
}), [wrappedPush, wrappedPushMany, streaming.resetCategories, accumulatorRef])

// ── Chart setup ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -457,7 +470,7 @@ export const LikertChart = forwardRef(function LikertChart<TDatum extends Record
)
}) as unknown as {
<TDatum extends Record<string, any> = Record<string, any>>(
props: LikertChartProps<TDatum> & React.RefAttributes<RealtimeFrameHandle>
props: LikertChartProps<TDatum> & React.RefAttributes<LikertChartHandle>
): React.ReactElement | null
displayName?: string
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/charts/ordinal/RidgelinePlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ export const RidgelinePlot = forwardRef(function RidgelinePlot<TDatum extends Re
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 {
Expand Down
Loading
Loading