basalt-ui 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +38 -2
  4. package/agent/rules/basalt-charts.md +260 -108
  5. package/agent/rules/basalt-mantine.md +4 -2
  6. package/agent/skills/basalt-charts/SKILL.md +114 -64
  7. package/agent/skills/basalt-design/SKILL.md +4 -3
  8. package/configs/oxlint-plugin.js +152 -0
  9. package/configs/oxlint-plugin.test.ts +107 -0
  10. package/configs/oxlint.json +2 -0
  11. package/dist/charts/cursor/resolve.d.ts +33 -0
  12. package/dist/charts/cursor/resolve.d.ts.map +1 -0
  13. package/dist/charts/cursor/resolve.js +61 -0
  14. package/dist/charts/cursor/resolve.test.d.ts +2 -0
  15. package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
  16. package/dist/charts/cursor/scope.d.ts +17 -0
  17. package/dist/charts/cursor/scope.d.ts.map +1 -0
  18. package/dist/charts/cursor/scope.js +20 -0
  19. package/dist/charts/cursor/store.d.ts +31 -0
  20. package/dist/charts/cursor/store.d.ts.map +1 -0
  21. package/dist/charts/cursor/store.js +25 -0
  22. package/dist/charts/cursor/store.test.d.ts +2 -0
  23. package/dist/charts/cursor/store.test.d.ts.map +1 -0
  24. package/dist/charts/hooks/useChartCursor.d.ts +39 -0
  25. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
  26. package/dist/charts/hooks/useChartCursor.js +104 -0
  27. package/dist/charts/hooks/useChartSize.d.ts +8 -4
  28. package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
  29. package/dist/charts/index.d.ts +16 -13
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/index.js +38 -19
  32. package/dist/charts/kinds/Bars.d.ts +25 -47
  33. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  34. package/dist/charts/kinds/Bars.js +230 -390
  35. package/dist/charts/kinds/Donut.d.ts +5 -4
  36. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  37. package/dist/charts/kinds/Donut.js +17 -14
  38. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  39. package/dist/charts/kinds/DualPanel.js +136 -90
  40. package/dist/charts/kinds/Heatmap.d.ts +15 -11
  41. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  42. package/dist/charts/kinds/Heatmap.js +38 -22
  43. package/dist/charts/kinds/MultiLine.d.ts +27 -35
  44. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  45. package/dist/charts/kinds/MultiLine.js +114 -237
  46. package/dist/charts/kinds/StackedArea.d.ts +21 -10
  47. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  48. package/dist/charts/kinds/StackedArea.js +74 -132
  49. package/dist/charts/kinds/ZonedLine.d.ts +24 -40
  50. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  51. package/dist/charts/kinds/ZonedLine.js +103 -184
  52. package/dist/charts/layout/auto-margin.d.ts +60 -0
  53. package/dist/charts/layout/auto-margin.d.ts.map +1 -0
  54. package/dist/charts/layout/auto-margin.js +37 -0
  55. package/dist/charts/layout/auto-margin.test.d.ts +2 -0
  56. package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
  57. package/dist/charts/primitives/CartesianChart.d.ts +143 -0
  58. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
  59. package/dist/charts/primitives/CartesianChart.js +349 -0
  60. package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
  61. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
  62. package/dist/charts/primitives/ChartFrame.d.ts +16 -5
  63. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartFrame.js +17 -2
  65. package/dist/charts/primitives/ChartLegend.d.ts +12 -1
  66. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartLegend.js +21 -3
  68. package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
  69. package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
  70. package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +45 -19
  73. package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
  74. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  75. package/dist/charts/primitives/HoverOverlay.js +22 -1
  76. package/dist/charts/primitives/XZoneRects.d.ts +40 -0
  77. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
  78. package/dist/charts/primitives/XZoneRects.js +28 -0
  79. package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
  80. package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
  81. package/dist/charts/series.d.ts +16 -1
  82. package/dist/charts/series.d.ts.map +1 -1
  83. package/dist/charts/series.js +3 -1
  84. package/dist/charts/series.test.d.ts +2 -0
  85. package/dist/charts/series.test.d.ts.map +1 -0
  86. package/dist/charts/utils/measure-text.d.ts +25 -0
  87. package/dist/charts/utils/measure-text.d.ts.map +1 -0
  88. package/dist/charts/utils/measure-text.js +64 -0
  89. package/dist/charts/utils/measure-text.test.d.ts +2 -0
  90. package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
  91. package/dist/content/fence-block.d.ts.map +1 -1
  92. package/dist/surfaces.d.ts +1 -1
  93. package/dist/surfaces.d.ts.map +1 -1
  94. package/dist/surfaces.js +1 -1
  95. package/dist/tokens/chart-margin.test.d.ts +2 -0
  96. package/dist/tokens/chart-margin.test.d.ts.map +1 -0
  97. package/dist/tokens/index.d.ts +29 -0
  98. package/dist/tokens/index.d.ts.map +1 -1
  99. package/dist/tokens/index.js +10 -0
  100. package/dist/tokens/palette.d.ts +4 -0
  101. package/dist/tokens/palette.d.ts.map +1 -1
  102. package/dist/tokens/palette.js +3 -0
  103. package/llms.txt +3 -3
  104. package/package.json +1 -1
  105. package/src/charts/cursor/resolve.test.ts +61 -0
  106. package/src/charts/cursor/resolve.ts +102 -0
  107. package/src/charts/cursor/scope.tsx +32 -0
  108. package/src/charts/cursor/store.test.ts +46 -0
  109. package/src/charts/cursor/store.ts +53 -0
  110. package/src/charts/hooks/useChartCursor.ts +163 -0
  111. package/src/charts/hooks/useChartSize.ts +8 -4
  112. package/src/charts/index.ts +34 -14
  113. package/src/charts/kinds/Bars.tsx +327 -460
  114. package/src/charts/kinds/Donut.tsx +31 -23
  115. package/src/charts/kinds/DualPanel.tsx +228 -134
  116. package/src/charts/kinds/Heatmap.tsx +72 -47
  117. package/src/charts/kinds/MultiLine.tsx +158 -294
  118. package/src/charts/kinds/StackedArea.tsx +116 -175
  119. package/src/charts/kinds/ZonedLine.tsx +148 -255
  120. package/src/charts/layout/auto-margin.test.ts +42 -0
  121. package/src/charts/layout/auto-margin.ts +110 -0
  122. package/src/charts/primitives/CartesianChart.test.ts +99 -0
  123. package/src/charts/primitives/CartesianChart.tsx +566 -0
  124. package/src/charts/primitives/ChartFrame.test.tsx +33 -0
  125. package/src/charts/primitives/ChartFrame.tsx +40 -4
  126. package/src/charts/primitives/ChartLegend.test.tsx +48 -0
  127. package/src/charts/primitives/ChartLegend.tsx +45 -2
  128. package/src/charts/primitives/ChartTooltip.tsx +67 -37
  129. package/src/charts/primitives/HoverOverlay.tsx +39 -3
  130. package/src/charts/primitives/XZoneRects.test.tsx +63 -0
  131. package/src/charts/primitives/XZoneRects.tsx +67 -0
  132. package/src/charts/series.test.ts +61 -0
  133. package/src/charts/series.ts +18 -1
  134. package/src/charts/utils/measure-text.test.ts +36 -0
  135. package/src/charts/utils/measure-text.ts +109 -0
  136. package/src/content/fence-block.tsx +2 -3
  137. package/src/surfaces.ts +2 -1
  138. package/src/theme/spacing.test.ts +2 -0
  139. package/src/tokens/chart-margin.test.ts +39 -0
  140. package/src/tokens/density.test.ts +1 -0
  141. package/src/tokens/index.ts +36 -0
  142. package/src/tokens/palette.ts +3 -0
  143. package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
  144. package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
  145. package/dist/charts/hooks/useChartTooltip.js +0 -26
  146. package/dist/charts/hooks/useHoverSync.d.ts +0 -46
  147. package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
  148. package/dist/charts/hooks/useHoverSync.js +0 -75
  149. package/dist/charts/hover-context.d.ts +0 -21
  150. package/dist/charts/hover-context.d.ts.map +0 -1
  151. package/dist/charts/hover-context.js +0 -12
  152. package/dist/charts/hover-sync.d.ts +0 -14
  153. package/dist/charts/hover-sync.d.ts.map +0 -1
  154. package/dist/charts/hover-sync.js +0 -17
  155. package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
  156. package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
  157. package/dist/charts/primitives/ResponsiveChart.js +0 -24
  158. package/src/charts/hooks/useChartTooltip.ts +0 -31
  159. package/src/charts/hooks/useHoverSync.ts +0 -152
  160. package/src/charts/hover-context.ts +0 -28
  161. package/src/charts/hover-sync.tsx +0 -37
  162. package/src/charts/primitives/ResponsiveChart.tsx +0 -64
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { ChartSeries } from '../series'
3
+ import { resolveAxisDomain } from './CartesianChart'
4
+
5
+ type Row = { date: string; a: number; b: number }
6
+
7
+ const rows: Row[] = [
8
+ { date: '2026-08-01', a: 10, b: 4 },
9
+ { date: '2026-08-02', a: 40, b: 6 },
10
+ { date: '2026-08-03', a: 25, b: 5 },
11
+ ]
12
+
13
+ const seriesFor = (key: 'a' | 'b'): ChartSeries<Row> => ({
14
+ key,
15
+ label: key,
16
+ color: '#000',
17
+ mark: 'line',
18
+ getValue: (d) => d[key],
19
+ })
20
+
21
+ const both = [seriesFor('a'), seriesFor('b')]
22
+
23
+ describe('resolveAxisDomain', () => {
24
+ test('a fixed tuple passes through untouched', () => {
25
+ expect(resolveAxisDomain({ domain: [0, 100] }, rows, both)).toEqual([0, 100])
26
+ })
27
+
28
+ test('auto pads the upper bound away from zero and keeps the zero baseline', () => {
29
+ const [min, max] = resolveAxisDomain(undefined, rows, both)
30
+ expect(min).toBe(0)
31
+ expect(max).toBeCloseTo(44, 5)
32
+ })
33
+
34
+ test('auto over an ALL-NEGATIVE series keeps the largest value inside the plot', () => {
35
+ // `max * pad` would push the upper bound to -5.5, i.e. below the largest datum, clipping it.
36
+ const negative: ChartSeries<Row>[] = [
37
+ { ...seriesFor('a'), getValue: (d) => -d.a },
38
+ { ...seriesFor('b'), getValue: (d) => -d.b },
39
+ ]
40
+ const [min, max] = resolveAxisDomain({ autoMinCeil: Infinity }, rows, negative)
41
+ expect(max).toBeGreaterThanOrEqual(-4)
42
+ expect(min).toBeLessThanOrEqual(-40)
43
+ })
44
+
45
+ test('autoMaxFloor raises a low upper bound', () => {
46
+ const [, max] = resolveAxisDomain({ autoMaxFloor: 500 }, rows, both)
47
+ expect(max).toBe(500)
48
+ })
49
+
50
+ test('the domain follows the VISIBLE series — hiding the tall one shrinks the axis', () => {
51
+ const [, withBoth] = resolveAxisDomain(undefined, rows, both)
52
+ const [, onlyB] = resolveAxisDomain(undefined, rows, [seriesFor('b')])
53
+ expect(onlyB).toBeLessThan(withBoth)
54
+ })
55
+
56
+ test('a domain function receives the data AND the visible series', () => {
57
+ const domain = resolveAxisDomain(
58
+ { domain: (data, visible) => [0, data.length * visible.length] },
59
+ rows,
60
+ both,
61
+ )
62
+ expect(domain).toEqual([0, 6])
63
+ })
64
+
65
+ test('empty data falls back to a usable unit domain', () => {
66
+ expect(resolveAxisDomain(undefined, [], both)).toEqual([0, 1])
67
+ })
68
+ })
69
+
70
+ describe('resolveAxisDomain — overlay bounds', () => {
71
+ test('a zone past the data stretches the axis instead of clipping', () => {
72
+ const [, max] = resolveAxisDomain(undefined, rows, both, [0, 200])
73
+ expect(max).toBeGreaterThanOrEqual(200)
74
+ })
75
+
76
+ test('a reference line below the data lowers the floor', () => {
77
+ const [min] = resolveAxisDomain({ autoMinCeil: Infinity }, rows, both, [-30])
78
+ expect(min).toBeLessThanOrEqual(-30)
79
+ })
80
+
81
+ test('infinite bounds ("top/bottom of axis") are ignored, not blown up', () => {
82
+ const plain = resolveAxisDomain(undefined, rows, both)
83
+ expect(resolveAxisDomain(undefined, rows, both, [-Infinity, Infinity])).toEqual(plain)
84
+ })
85
+ })
86
+
87
+ describe('resolveAxisDomain — degenerate input', () => {
88
+ test('a flat all-zero series still gets a usable axis, not a zero-extent scale', () => {
89
+ const flat: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 0 }]
90
+ const [min, max] = resolveAxisDomain(undefined, rows, flat)
91
+ expect(max).toBeGreaterThan(min)
92
+ })
93
+
94
+ test('a flat non-zero series is not collapsed onto its own value', () => {
95
+ const flat: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 7 }]
96
+ const [min, max] = resolveAxisDomain({ autoMinCeil: Infinity }, rows, flat)
97
+ expect(max).toBeGreaterThan(min)
98
+ })
99
+ })
@@ -0,0 +1,566 @@
1
+ import { GridRows } from '@visx/grid'
2
+ import { Group } from '@visx/group'
3
+ import { scaleLinear, scalePoint } from '@visx/scale'
4
+ import { useMemo, useRef, useState } from 'react'
5
+ import type { ReactNode } from 'react'
6
+ import { VX } from '../../tokens'
7
+ import type { ChartMargin } from '../../tokens'
8
+ import { useChartCursor } from '../hooks/useChartCursor'
9
+ import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
10
+ import { deriveTooltipRows } from '../series'
11
+ import type { ChartLegendConfig, ChartSeries } from '../series'
12
+ import { padAutoLower } from '../utils/domain'
13
+ import { fmtAxisDate } from '../utils/format'
14
+ import { smartTicks, smartTicksEvery } from '../utils/ticks'
15
+ import { AxisBottomDate, AxisLeftNumeric, AxisRightNumeric } from './Axes'
16
+ import { ChartFrame, resolveLegend } from './ChartFrame'
17
+ import { ChartTooltipFloat, TooltipBody, TooltipHeader, TooltipRow } from './ChartTooltip'
18
+ import { Crosshair, SeriesDot } from './Crosshair'
19
+ import { HoverOverlay } from './HoverOverlay'
20
+ import { XZoneRects } from './XZoneRects'
21
+ import type { XZoneSpec } from './XZoneRects'
22
+ import { ZoneRects } from './ZoneRects'
23
+ import type { ZoneSpec } from './ZoneRects'
24
+
25
+ type LinearScale = ReturnType<typeof scaleLinear<number>>
26
+ type PointScale = ReturnType<typeof scalePoint<string>>
27
+
28
+ const DEFAULT_TICKS = 5
29
+ const DEFAULT_AUTO_PAD = 1.1
30
+
31
+ /** One y-axis, fully described. Collapses the removed `yDomain` / `yAutoMaxFloor` / `yAutoMinCeil` /
32
+ * `yAutoPad` / `numTicksY` / `formatYTick` prop soup into a single object per axis. */
33
+ export type AxisConfig<T> = {
34
+ /**
35
+ * `'auto'` (default) derives the domain from the VISIBLE series; a tuple fixes it; a function
36
+ * computes it from the data — the seam a stacked chart uses to sum its bands. The function also
37
+ * receives the visible series, so a stacked domain SHRINKS when the legend toggles a band off
38
+ * (summing `props.series` there would leave a permanent gap above the stack).
39
+ */
40
+ domain?:
41
+ | [number, number]
42
+ | 'auto'
43
+ | ((data: readonly T[], visible: readonly ChartSeries<T>[]) => [number, number])
44
+ /** When 'auto': the upper bound is at least this. */
45
+ autoMaxFloor?: number
46
+ /** When 'auto': the lower bound is at most this. Default 0 (forced zero baseline). `Infinity`
47
+ * pads down from the raw data minimum instead. */
48
+ autoMinCeil?: number
49
+ /** When 'auto': padding multiplier applied away from the data. Default 1.1. */
50
+ autoPad?: number
51
+ ticks?: number
52
+ /** Tick + tooltip formatter for this axis. Defaults to d3's own tick format. */
53
+ format?: (v: number) => string
54
+ /** Horizontal grid rules. Default: on for the left axis, off for the right. */
55
+ grid?: boolean
56
+ }
57
+
58
+ export type CartesianTooltipConfig<T> = {
59
+ /**
60
+ * Default true — the tooltip tracks the pointer. Set false to ANCHOR it to the crosshair at the
61
+ * plot's top edge instead, which reads better across a column of charts sharing one cursor
62
+ * (every tooltip then lines up on the same x). Anchoring costs one `getBoundingClientRect` per
63
+ * hovered frame, which is why following stays the default.
64
+ */
65
+ follow?: boolean
66
+ /** Right-aligned badge in the tooltip header (e.g. a status label). */
67
+ label?: (d: T) => { text: string; color: string } | null
68
+ /** Rows rendered BEFORE the derived per-series rows (a total, a context line). */
69
+ prependRows?: (d: T) => ReactNode
70
+ /** Rows appended after the derived per-series rows. */
71
+ extraRows?: (d: T) => ReactNode
72
+ }
73
+
74
+ /** Everything a mark renderer needs. Handed to `CartesianChart`'s child on every render. */
75
+ export type PlotContext<T> = {
76
+ data: readonly T[]
77
+ /** Series minus the ones the legend has toggled off — draw THESE, not `props.series`. */
78
+ visible: ChartSeries<T>[]
79
+ hidden: ReadonlySet<string>
80
+ xScale: PointScale
81
+ yScale: LinearScale
82
+ /** Present only when a `y2` axis is configured. */
83
+ y2Scale: LinearScale | null
84
+ xMax: number
85
+ yMax: number
86
+ margin: ChartMargin
87
+ /** The point the shared cursor currently rests on, or null. */
88
+ cursorPoint: T | null
89
+ /** Series key the legend is hovering, for mark dimming. */
90
+ highlighted: string | null
91
+ }
92
+
93
+ export type CartesianChartProps<T> = {
94
+ data: T[]
95
+ /** Stable id — identifies this chart as the cursor's source. */
96
+ chartId: string
97
+ /** Extracts the x-domain key (a date or category string) from a point. */
98
+ getX: (d: T) => string
99
+ /** The single source of truth for marks, legend entries, and tooltip rows. */
100
+ series: ChartSeries<T>[]
101
+ y?: AxisConfig<T>
102
+ /** Passing `y2` is what makes the chart dual-axis: it draws the right axis and widens the
103
+ * right margin by measurement. Series opt in with `axis: 'right'`. */
104
+ y2?: AxisConfig<T>
105
+ /** Exact number of x ticks. Default: as many as fit (`smartTicks`). */
106
+ xTicks?: number
107
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
108
+ formatX?: (key: string) => string
109
+ /** Value-range bands on the left scale, drawn behind the marks. */
110
+ zones?: ZoneSpec[]
111
+ /** X-range bands (time windows), drawn behind the marks. */
112
+ xZones?: XZoneSpec[]
113
+ refLines?: { value: number; color: string; dashed?: boolean; axis?: 'left' | 'right' }[]
114
+ height?: number
115
+ aspectRatio?: number
116
+ fill?: boolean
117
+ legend?: ChartLegendConfig | false
118
+ /** `false` disables the tooltip entirely (and with it the crosshair dots). */
119
+ tooltip?: CartesianTooltipConfig<T> | false
120
+ /** Per-side overrides of the measured margins — the escape hatch, applied last. */
121
+ margin?: Partial<ChartMargin>
122
+ /**
123
+ * Where this series' crosshair dot sits, as a value in the axis domain. Defaults to
124
+ * `series.getValue`, which is correct for every unstacked chart. A STACKED chart must override
125
+ * it with the cumulative band top — otherwise its dots sit at each band's raw value, off the
126
+ * line the reader is actually looking at.
127
+ */
128
+ cursorValue?: (
129
+ point: T,
130
+ series: ChartSeries<T>,
131
+ visible: readonly ChartSeries<T>[],
132
+ ) => number | null
133
+ ariaLabel?: string
134
+ isPending?: boolean
135
+ /** Draw ONLY the marks. Everything around them is the primitive's job. */
136
+ children: (ctx: PlotContext<T>) => ReactNode
137
+ }
138
+
139
+ /**
140
+ * Resolve one axis' `[min, max]` from its {@link AxisConfig} and the VISIBLE series. Exported for
141
+ * testing and for a hand-composed kind that needs the same auto-domain law `CartesianChart`
142
+ * applies (rather than reinventing the padding rules per chart).
143
+ */
144
+ export function resolveAxisDomain<T>(
145
+ cfg: AxisConfig<T> | undefined,
146
+ data: readonly T[],
147
+ series: readonly ChartSeries<T>[],
148
+ /**
149
+ * Values that must stay inside the plot even though no series reports them — the bounds of the
150
+ * zones and reference lines pinned to THIS axis. Without them a target zone or a stretch-goal
151
+ * ref line sitting past the data silently clips, which is precisely the case those overlays
152
+ * exist for. Infinite bounds ("top/bottom of axis") are ignored: they clamp to the domain by
153
+ * definition and would otherwise blow it up.
154
+ */
155
+ extraBounds: readonly number[] = [],
156
+ ): [number, number] {
157
+ const domain = cfg?.domain ?? 'auto'
158
+ if (Array.isArray(domain)) return domain
159
+ if (typeof domain === 'function') return domain(data, series)
160
+
161
+ let min = Infinity
162
+ let max = -Infinity
163
+ for (const d of data) {
164
+ for (const s of series) {
165
+ const v = s.getValue(d)
166
+ if (v === null) continue
167
+ if (v < min) min = v
168
+ if (v > max) max = v
169
+ }
170
+ }
171
+ for (const v of extraBounds) {
172
+ if (!Number.isFinite(v)) continue
173
+ if (v < min) min = v
174
+ if (v > max) max = v
175
+ }
176
+ if (min === Infinity) return [0, cfg?.autoMaxFloor ?? 1]
177
+
178
+ const pad = cfg?.autoPad ?? DEFAULT_AUTO_PAD
179
+ const ceil = cfg?.autoMinCeil ?? 0
180
+ // Pad AWAY from zero, never by multiplying: `max * pad` on an all-negative series moves the
181
+ // upper bound further negative (`-5 * 1.1 = -5.5`), i.e. past the data, clipping the largest
182
+ // value out of the plot. Mirrors `padAutoLower`'s reasoning for the lower bound.
183
+ const paddedMax = max > 0 ? max * pad : max < 0 ? max / pad : 0
184
+ const upper = Math.max(paddedMax, cfg?.autoMaxFloor ?? -Infinity)
185
+ const lower = padAutoLower(Math.min(min, ceil), pad)
186
+ // A flat series (every value identical, the all-zero case included) collapses to `[n, n]`,
187
+ // which is a scale with no extent: every point maps to the same pixel and the line renders as a
188
+ // dot on the axis. Give it the same usable baseline the empty-data branch above gets.
189
+ if (lower === upper) return [Math.min(lower, 0), upper === 0 ? 1 : upper * pad]
190
+ return [lower, upper]
191
+ }
192
+
193
+ /**
194
+ * The cartesian chart primitive — the rung that was missing between `ChartFrame` and raw visx
195
+ * (`docs/CHARTS-SPEC.md` §2).
196
+ *
197
+ * It owns, once, everything every chart repeated: measured margins, both y scales and their
198
+ * domains, the x scale and its tick thinning, grid, zones, axes, the shared cursor, the crosshair
199
+ * and its per-series dots, the hover/keyboard overlay, and the derived tooltip. The child draws
200
+ * marks and nothing else, so two charts cannot drift apart in any of those layers — that identity
201
+ * IS the consistency guarantee.
202
+ *
203
+ * Margins are MEASURED from the labels that will actually be painted, so a wide tick label widens
204
+ * its own gutter instead of clipping or needing a hand-tuned `margin` prop.
205
+ */
206
+ export function CartesianChart<T>({
207
+ data,
208
+ chartId,
209
+ getX,
210
+ series,
211
+ y,
212
+ y2,
213
+ xTicks,
214
+ formatX = fmtAxisDate,
215
+ zones,
216
+ xZones,
217
+ refLines,
218
+ height,
219
+ aspectRatio,
220
+ fill,
221
+ legend,
222
+ tooltip,
223
+ margin: marginOverride,
224
+ cursorValue,
225
+ ariaLabel,
226
+ isPending,
227
+ children,
228
+ }: CartesianChartProps<T>): ReactNode {
229
+ const [highlighted, setHighlighted] = useState<string | null>(null)
230
+
231
+ return (
232
+ <ChartFrame
233
+ series={series}
234
+ chartId={chartId}
235
+ {...(height !== undefined && { height })}
236
+ {...(aspectRatio !== undefined && { aspectRatio })}
237
+ {...(fill !== undefined && { fill })}
238
+ {...(ariaLabel !== undefined && { ariaLabel })}
239
+ {...(isPending !== undefined && { isPending })}
240
+ legend={resolveLegend(legend, { highlighted, onHighlight: setHighlighted })}
241
+ >
242
+ {(plot) => (
243
+ <CartesianPlot
244
+ data={data}
245
+ chartId={chartId}
246
+ getX={getX}
247
+ series={series}
248
+ {...(y !== undefined && { y })}
249
+ {...(y2 !== undefined && { y2 })}
250
+ {...(xTicks !== undefined && { xTicks })}
251
+ formatX={formatX}
252
+ {...(zones !== undefined && { zones })}
253
+ {...(xZones !== undefined && { xZones })}
254
+ {...(refLines !== undefined && { refLines })}
255
+ {...(tooltip !== undefined && { tooltip })}
256
+ {...(marginOverride !== undefined && { marginOverride })}
257
+ {...(cursorValue !== undefined && { cursorValue })}
258
+ {...(ariaLabel !== undefined && { ariaLabel })}
259
+ plot={plot}
260
+ highlighted={highlighted}
261
+ >
262
+ {children}
263
+ </CartesianPlot>
264
+ )}
265
+ </ChartFrame>
266
+ )
267
+ }
268
+
269
+ type CartesianPlotProps<T> = Omit<
270
+ CartesianChartProps<T>,
271
+ 'height' | 'aspectRatio' | 'fill' | 'legend' | 'isPending' | 'margin'
272
+ > & {
273
+ plot: { width: number; height: number; hidden: ReadonlySet<string> }
274
+ highlighted: string | null
275
+ marginOverride?: Partial<ChartMargin>
276
+ }
277
+
278
+ /** Split from {@link CartesianChart} so scale + margin work runs only once the frame has resolved
279
+ * a non-empty plot rect (and so the hook order never depends on that rect). */
280
+ function CartesianPlot<T>({
281
+ data,
282
+ chartId,
283
+ getX,
284
+ series,
285
+ y,
286
+ y2,
287
+ xTicks,
288
+ formatX = fmtAxisDate,
289
+ zones,
290
+ xZones,
291
+ refLines,
292
+ tooltip,
293
+ marginOverride,
294
+ cursorValue,
295
+ ariaLabel,
296
+ plot,
297
+ highlighted,
298
+ children,
299
+ }: CartesianPlotProps<T>): ReactNode {
300
+ const { hidden } = plot
301
+
302
+ const visible = useMemo(() => series.filter((s) => !hidden.has(s.key)), [series, hidden])
303
+ const leftSeries = useMemo(() => visible.filter((s) => s.axis !== 'right'), [visible])
304
+ const rightSeries = useMemo(() => visible.filter((s) => s.axis === 'right'), [visible])
305
+
306
+ // Honest deps on the accessors, deliberately: a ref would keep the scales, ticks and margins
307
+ // keyed on `data` alone, and then swapping which field `getX` reads (a field picker over the
308
+ // same rows) would render marks through a new accessor against stale scales. The re-render this
309
+ // costs is narrower than it looks — a cursor move re-renders `CartesianPlot` without changing
310
+ // its props, so hover frames reuse these memos regardless.
311
+ const keys = useMemo(() => data.map(getX), [data, getX])
312
+
313
+ // ── Pass 1: domains + tick labels, both independent of the pixel range ──────────────────────
314
+ // This is what breaks the margin/scale circularity: a linear scale's tick VALUES depend only on
315
+ // its domain, so the labels can be measured before the plot rect exists.
316
+ // Overlay bounds per axis — see `resolveAxisDomain`'s `extraBounds`.
317
+ const [leftBounds, rightBounds] = useMemo(() => {
318
+ const left: number[] = []
319
+ const right: number[] = []
320
+ for (const z of zones ?? []) (z.axisSide === 'right' ? right : left).push(z.from, z.to)
321
+ for (const l of refLines ?? []) (l.axis === 'right' ? right : left).push(l.value)
322
+ return [left, right]
323
+ }, [zones, refLines])
324
+
325
+ const leftDomain = useMemo(
326
+ () => resolveAxisDomain(y, data, leftSeries, leftBounds),
327
+ [y, data, leftSeries, leftBounds],
328
+ )
329
+ const rightDomain = useMemo(
330
+ () => (y2 === undefined ? null : resolveAxisDomain(y2, data, rightSeries, rightBounds)),
331
+ [y2, data, rightSeries, rightBounds],
332
+ )
333
+
334
+ const leftTicks = y?.ticks ?? DEFAULT_TICKS
335
+ const rightTicks = y2?.ticks ?? DEFAULT_TICKS
336
+
337
+ const { labels: leftLabels, format: leftFormat } = useMemo(
338
+ () =>
339
+ probeAxisLabels({
340
+ domain: leftDomain,
341
+ ticks: leftTicks,
342
+ ...(y?.format !== undefined && { format: y.format }),
343
+ }),
344
+ [leftDomain, leftTicks, y?.format],
345
+ )
346
+
347
+ const { labels: rightLabels, format: rightFormat } = useMemo(
348
+ () =>
349
+ rightDomain === null
350
+ ? { labels: [], format: (v: number) => String(v) }
351
+ : probeAxisLabels({
352
+ domain: rightDomain,
353
+ ticks: rightTicks,
354
+ ...(y2?.format !== undefined && { format: y2.format }),
355
+ }),
356
+ [rightDomain, rightTicks, y2?.format],
357
+ )
358
+
359
+ const xLabels = useMemo(() => keys.map(formatX), [keys, formatX])
360
+
361
+ const margin = useMemo(
362
+ () =>
363
+ autoMargin({
364
+ left: leftLabels,
365
+ right: rightLabels,
366
+ bottom: xLabels,
367
+ ...(marginOverride !== undefined && { override: marginOverride }),
368
+ }),
369
+ [leftLabels, rightLabels, xLabels, marginOverride],
370
+ )
371
+
372
+ // ── Pass 2: the real scales, now that the plot rect is known ────────────────────────────────
373
+ const xMax = Math.max(plot.width - margin.left - margin.right, 0)
374
+ const yMax = Math.max(plot.height - margin.top - margin.bottom, 0)
375
+
376
+ const xScale = useMemo(
377
+ () => scalePoint<string>({ domain: keys, range: [0, xMax], padding: 0.5 }),
378
+ [keys, xMax],
379
+ )
380
+ const yScale = useMemo(
381
+ () => scaleLinear<number>({ domain: leftDomain, range: [yMax, 0] }),
382
+ [leftDomain, yMax],
383
+ )
384
+ const y2Scale = useMemo(
385
+ () =>
386
+ rightDomain === null ? null : scaleLinear<number>({ domain: rightDomain, range: [yMax, 0] }),
387
+ [rightDomain, yMax],
388
+ )
389
+
390
+ const tickValues = useMemo(
391
+ () => (xTicks === undefined ? smartTicks(keys, xMax) : smartTicksEvery(keys, xTicks)),
392
+ [keys, xMax, xTicks],
393
+ )
394
+
395
+ const cursor = useChartCursor<T>({
396
+ data,
397
+ chartId,
398
+ getKey: getX,
399
+ xScale,
400
+ marginLeft: margin.left,
401
+ })
402
+
403
+ const svgRef = useRef<SVGSVGElement>(null)
404
+ const tooltipEnabled = tooltip !== false
405
+ const tooltipCfg = tooltip === false ? undefined : tooltip
406
+ const point = cursor.point
407
+ const cursorX = point === null ? null : (xScale(getX(point)) ?? null)
408
+ const badge = tooltipCfg?.label === undefined || point === null ? null : tooltipCfg.label(point)
409
+
410
+ // Each series falls back to ITS OWN axis's tick format, so a right-axis value is never
411
+ // formatted with the left axis's rules (an explicit per-series `formatValue` still wins).
412
+ const tooltipSeries = useMemo(
413
+ () =>
414
+ visible.map((s) =>
415
+ s.formatValue === undefined
416
+ ? { ...s, formatValue: s.axis === 'right' ? rightFormat : leftFormat }
417
+ : s,
418
+ ),
419
+ [visible, leftFormat, rightFormat],
420
+ )
421
+
422
+ const rows =
423
+ tooltipEnabled && point !== null ? deriveTooltipRows(tooltipSeries, point, leftFormat) : []
424
+
425
+ // Anchored mode resolves the crosshair's plot-local x into viewport space; following mode uses
426
+ // the pointer position `useChartCursor` already recorded.
427
+ const anchorX =
428
+ tooltipCfg?.follow === false && point !== null && cursorX !== null ? cursorX : null
429
+ const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
430
+ const tooltipAnchor =
431
+ svgRect === undefined || anchorX === null
432
+ ? cursor.anchor
433
+ : { x: svgRect.left + margin.left + anchorX, y: svgRect.top + margin.top }
434
+
435
+ const ctx: PlotContext<T> = {
436
+ data,
437
+ visible,
438
+ hidden,
439
+ xScale,
440
+ yScale,
441
+ y2Scale,
442
+ xMax,
443
+ yMax,
444
+ margin,
445
+ cursorPoint: point,
446
+ highlighted,
447
+ }
448
+
449
+ return (
450
+ <>
451
+ <svg ref={svgRef} width={plot.width} height={plot.height}>
452
+ <Group left={margin.left} top={margin.top}>
453
+ {(y?.grid ?? true) && (
454
+ <GridRows scale={yScale} width={xMax} stroke={VX.grid} numTicks={leftTicks} />
455
+ )}
456
+ {xZones !== undefined && <XZoneRects zones={xZones} height={yMax} xScale={xScale} />}
457
+ {zones !== undefined && (
458
+ <ZoneRects zones={zones} width={xMax} leftScale={yScale} rightScale={y2Scale} />
459
+ )}
460
+
461
+ {children(ctx)}
462
+
463
+ {refLines?.map((line) => {
464
+ const scale = line.axis === 'right' && y2Scale !== null ? y2Scale : yScale
465
+ return (
466
+ <line
467
+ key={`${line.axis ?? 'left'}-${line.value}`}
468
+ x1={0}
469
+ x2={xMax}
470
+ y1={scale(line.value)}
471
+ y2={scale(line.value)}
472
+ stroke={line.color}
473
+ strokeWidth={1}
474
+ strokeDasharray={line.dashed === true ? VX.dashArray : undefined}
475
+ />
476
+ )
477
+ })}
478
+
479
+ {tooltipEnabled && point !== null && cursorX !== null && (
480
+ <>
481
+ <Crosshair x={cursorX} top={0} bottom={yMax} />
482
+ {visible.map((s) => {
483
+ // A dot on a bar is noise — the hovered column is already marked by the crosshair,
484
+ // and the dot would land inside the fill rather than on an edge a reader tracks.
485
+ if (s.mark === 'bar') return null
486
+ const value =
487
+ cursorValue === undefined ? s.getValue(point) : cursorValue(point, s, visible)
488
+ if (value === null) return null
489
+ const scale = s.axis === 'right' && y2Scale !== null ? y2Scale : yScale
490
+ const marker = s.getMarker?.(point)
491
+ return (
492
+ <SeriesDot
493
+ key={s.key}
494
+ cx={cursorX}
495
+ cy={scale(value)}
496
+ color={marker?.color ?? s.color}
497
+ {...(marker?.r !== undefined && { r: marker.r })}
498
+ />
499
+ )
500
+ })}
501
+ </>
502
+ )}
503
+
504
+ <AxisLeftNumeric
505
+ scale={yScale}
506
+ numTicks={leftTicks}
507
+ tickFormat={(v) => leftFormat(Number(v))}
508
+ />
509
+ {y2Scale !== null && (
510
+ <AxisRightNumeric
511
+ scale={y2Scale}
512
+ left={xMax}
513
+ numTicks={rightTicks}
514
+ tickFormat={(v) => rightFormat(Number(v))}
515
+ />
516
+ )}
517
+ <AxisBottomDate
518
+ top={yMax}
519
+ scale={xScale}
520
+ tickValues={tickValues}
521
+ tickFormat={(v) => formatX(String(v))}
522
+ />
523
+
524
+ <HoverOverlay
525
+ width={xMax}
526
+ height={yMax}
527
+ onMove={cursor.onPointerMove}
528
+ onLeave={cursor.onPointerLeave}
529
+ onKeyDown={cursor.onKeyDown}
530
+ onBlur={cursor.onBlur}
531
+ valueMax={Math.max(data.length - 1, 0)}
532
+ {...(point !== null && {
533
+ valueNow: data.indexOf(point),
534
+ valueText: formatX(getX(point)),
535
+ })}
536
+ {...(ariaLabel !== undefined && { ariaLabel })}
537
+ />
538
+ </Group>
539
+ </svg>
540
+
541
+ {tooltipEnabled && cursor.isSource && point !== null && (
542
+ <ChartTooltipFloat anchor={tooltipAnchor}>
543
+ <TooltipHeader
544
+ date={getX(point)}
545
+ {...(badge !== null && { label: badge.text, labelColor: badge.color })}
546
+ />
547
+ <TooltipBody>
548
+ {tooltipCfg?.prependRows?.(point)}
549
+ {rows.map((row) => (
550
+ <TooltipRow
551
+ key={row.key}
552
+ color={row.color}
553
+ label={row.label}
554
+ value={row.value}
555
+ shape={row.shape}
556
+ dashed={row.dashed}
557
+ {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
558
+ />
559
+ ))}
560
+ {tooltipCfg?.extraRows?.(point)}
561
+ </TooltipBody>
562
+ </ChartTooltipFloat>
563
+ )}
564
+ </>
565
+ )
566
+ }
@@ -65,3 +65,36 @@ describe('a non-pending ChartFrame is unaffected — the legend and body both re
65
65
  expect(markup).not.toContain('aria-busy')
66
66
  })
67
67
  })
68
+
69
+ const twoSeries: SeriesStyle[] = [
70
+ { key: 'a', label: 'Series A', color: '#000', mark: 'line' },
71
+ { key: 'b', label: 'Series B', color: '#111', mark: 'bar' },
72
+ ]
73
+
74
+ describe('legend toggling', () => {
75
+ test('a multi-series legend is interactive by default', () => {
76
+ const markup = renderToStaticMarkup(<ChartFrame series={twoSeries}>{() => <svg />}</ChartFrame>)
77
+ expect(markup).toContain('aria-pressed="true"')
78
+ })
79
+
80
+ test('a single-series legend is not — hiding the only series a chart draws is never useful', () => {
81
+ const markup = renderToStaticMarkup(<ChartFrame series={series}>{() => <svg />}</ChartFrame>)
82
+ expect(markup).not.toContain('aria-pressed')
83
+ })
84
+
85
+ test('`toggle: false` opts out even with several series', () => {
86
+ const markup = renderToStaticMarkup(
87
+ <ChartFrame series={twoSeries} legend={{ toggle: false }}>
88
+ {() => <svg />}
89
+ </ChartFrame>,
90
+ )
91
+ expect(markup).not.toContain('aria-pressed')
92
+ })
93
+
94
+ test('the child receives the (initially empty) hidden set', () => {
95
+ const markup = renderToStaticMarkup(
96
+ <ChartFrame series={twoSeries}>{({ hidden }) => <svg>size:{hidden.size}</svg>}</ChartFrame>,
97
+ )
98
+ expect(markup).toContain('size:0')
99
+ })
100
+ })