basalt-ui 1.15.0 → 1.17.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 (91) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/agent/rules/basalt-charts.md +74 -13
  3. package/agent/templates/CLAUDE-block.md.tpl +9 -6
  4. package/dist/charts/cursor/resolve.d.ts +29 -5
  5. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  6. package/dist/charts/cursor/resolve.js +25 -4
  7. package/dist/charts/hooks/useChartCursor.d.ts +8 -1
  8. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  9. package/dist/charts/hooks/useChartCursor.js +6 -2
  10. package/dist/charts/index.d.ts +2 -1
  11. package/dist/charts/index.d.ts.map +1 -1
  12. package/dist/charts/kinds/Bars.d.ts +30 -8
  13. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  14. package/dist/charts/kinds/Bars.js +10 -3
  15. package/dist/charts/kinds/Bars.test.d.ts +2 -0
  16. package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
  17. package/dist/charts/kinds/DualPanel.d.ts +30 -0
  18. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  19. package/dist/charts/kinds/DualPanel.js +82 -12
  20. package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
  21. package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
  22. package/dist/charts/kinds/MultiLine.d.ts +9 -0
  23. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  24. package/dist/charts/kinds/MultiLine.js +45 -33
  25. package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
  26. package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
  27. package/dist/charts/kinds/StackedArea.d.ts +9 -0
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/StackedArea.js +16 -1
  30. package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
  31. package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
  32. package/dist/charts/kinds/ZonedLine.d.ts +9 -0
  33. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  34. package/dist/charts/kinds/ZonedLine.js +5 -0
  35. package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
  36. package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
  37. package/dist/charts/primitives/CartesianChart.d.ts +45 -6
  38. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  39. package/dist/charts/primitives/CartesianChart.js +29 -12
  40. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -1
  41. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  42. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  43. package/dist/charts/primitives/ChartFrame.js +1 -1
  44. package/dist/charts/primitives/ChartLegend.d.ts +3 -0
  45. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  46. package/dist/charts/primitives/ChartLegend.js +4 -0
  47. package/dist/charts/primitives/ChartTooltip.d.ts +6 -1
  48. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  49. package/dist/charts/primitives/ChartTooltip.js +3 -2
  50. package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
  51. package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
  52. package/dist/charts/primitives/XZoneRects.d.ts +22 -8
  53. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -1
  54. package/dist/charts/primitives/XZoneRects.js +39 -2
  55. package/dist/charts/series.d.ts +16 -3
  56. package/dist/charts/series.d.ts.map +1 -1
  57. package/dist/charts/series.js +2 -1
  58. package/dist/charts/utils/domain.d.ts +15 -0
  59. package/dist/charts/utils/domain.d.ts.map +1 -1
  60. package/dist/charts/utils/domain.js +6 -1
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/cursor/resolve.test.ts +62 -0
  64. package/src/charts/cursor/resolve.ts +71 -7
  65. package/src/charts/hooks/useChartCursor.ts +12 -1
  66. package/src/charts/index.ts +2 -0
  67. package/src/charts/kinds/Bars.test.tsx +183 -0
  68. package/src/charts/kinds/Bars.tsx +41 -12
  69. package/src/charts/kinds/DualPanel.test.tsx +441 -0
  70. package/src/charts/kinds/DualPanel.tsx +127 -11
  71. package/src/charts/kinds/MultiLine.test.tsx +218 -0
  72. package/src/charts/kinds/MultiLine.tsx +64 -36
  73. package/src/charts/kinds/StackedArea.test.tsx +114 -0
  74. package/src/charts/kinds/StackedArea.tsx +25 -1
  75. package/src/charts/kinds/ZonedLine.test.tsx +147 -0
  76. package/src/charts/kinds/ZonedLine.tsx +14 -0
  77. package/src/charts/primitives/CartesianChart.test.tsx +269 -0
  78. package/src/charts/primitives/CartesianChart.tsx +79 -19
  79. package/src/charts/primitives/ChartFrame.test.tsx +42 -0
  80. package/src/charts/primitives/ChartFrame.tsx +9 -2
  81. package/src/charts/primitives/ChartLegend.test.tsx +30 -0
  82. package/src/charts/primitives/ChartLegend.tsx +7 -0
  83. package/src/charts/primitives/ChartTooltip.test.tsx +23 -0
  84. package/src/charts/primitives/ChartTooltip.tsx +7 -1
  85. package/src/charts/primitives/XZoneRects.test.tsx +60 -0
  86. package/src/charts/primitives/XZoneRects.tsx +75 -10
  87. package/src/charts/series.test.ts +51 -0
  88. package/src/charts/series.ts +18 -6
  89. package/src/charts/utils/domain.test.ts +31 -1
  90. package/src/charts/utils/domain.ts +19 -0
  91. package/src/charts/primitives/CartesianChart.test.ts +0 -99
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `StackedArea` — the `formatX` seam forwarded to `CartesianChart` (parity with the other
3
+ * `CartesianChart`-composing kinds). SSR harness, no hover needed.
4
+ */
5
+ import { fireEvent, render, screen } from '@testing-library/react'
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { renderToStaticMarkup } from 'react-dom/server'
8
+ import { ChartCursorScope } from '../cursor/scope'
9
+ import { CartesianChart } from '../primitives/CartesianChart'
10
+ import { StackedArea } from './StackedArea'
11
+ import type { ChartSeries } from '../series'
12
+
13
+ type Row = { date: string; a: number; b: number }
14
+
15
+ const rows: Row[] = [
16
+ { date: '2026-08-01', a: 10, b: 4 },
17
+ { date: '2026-08-02', a: 12, b: 6 },
18
+ ]
19
+
20
+ const series: ChartSeries<Row>[] = [
21
+ { key: 'a', label: 'A', color: '#111', mark: 'area', getValue: (d) => d.a },
22
+ { key: 'b', label: 'B', color: '#222', mark: 'area', getValue: (d) => d.b },
23
+ ]
24
+
25
+ describe('StackedArea — formatX', () => {
26
+ test('a custom formatX renders on the bottom axis instead of the default DD.MM', () => {
27
+ const html = renderToStaticMarkup(
28
+ <StackedArea<Row>
29
+ data={rows}
30
+ chartId="sa-formatx"
31
+ getX={(d) => d.date}
32
+ series={series}
33
+ formatX={(key) => `X:${key}`}
34
+ />,
35
+ )
36
+ expect(html).toContain('X:2026-08-01')
37
+ expect(html).not.toContain('>01.08<')
38
+ })
39
+
40
+ test('omitting formatX keeps the default DD.MM rendering', () => {
41
+ const html = renderToStaticMarkup(
42
+ <StackedArea<Row> data={rows} chartId="sa-default" getX={(d) => d.date} series={series} />,
43
+ )
44
+ expect(html).toContain('>01.08<')
45
+ })
46
+ })
47
+
48
+ describe('StackedArea — cursorResolution threads through to sibling resolution', () => {
49
+ // Daily calendar Aug 01–14 (a plain CartesianChart driver) alongside a StackedArea sibling
50
+ // folded into 2 weekly buckets keyed by each week's leading day (Aug 01, Aug 08) — parity with
51
+ // `Bars.test.tsx`'s equivalent block. Proves the prop reaches THIS kind, not just
52
+ // `CartesianChart` (a kind dropping the forward would silently fall back to 'nearest').
53
+ type DailyRow = { date: string; v: number }
54
+
55
+ const dailyRows: DailyRow[] = Array.from({ length: 14 }, (_, i) => ({
56
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
57
+ v: i,
58
+ }))
59
+ const foldedRows: Row[] = [
60
+ { date: '2026-08-01', a: 0, b: 0 },
61
+ { date: '2026-08-08', a: 1, b: 1 },
62
+ ]
63
+ const dailySeries: ChartSeries<DailyRow>[] = [
64
+ { key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
65
+ ]
66
+ const foldedSeries: ChartSeries<Row>[] = [
67
+ { key: 'a', label: 'A', color: '#111', mark: 'area', getValue: (d) => d.a },
68
+ ]
69
+
70
+ function renderPair(cursorResolution?: 'leading') {
71
+ render(
72
+ <ChartCursorScope>
73
+ <CartesianChart<DailyRow>
74
+ data={dailyRows}
75
+ chartId="sa-daily"
76
+ getX={(d) => d.date}
77
+ series={dailySeries}
78
+ ariaLabel="Daily"
79
+ >
80
+ {() => null}
81
+ </CartesianChart>
82
+ <StackedArea<Row>
83
+ data={foldedRows}
84
+ chartId="sa-folded"
85
+ getX={(d) => d.date}
86
+ series={foldedSeries}
87
+ ariaLabel="Folded"
88
+ {...(cursorResolution !== undefined && { cursorResolution })}
89
+ />
90
+ </ChartCursorScope>,
91
+ )
92
+ }
93
+
94
+ // Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
95
+ // via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
96
+ function driveDailyToAug05() {
97
+ const dailySlider = screen.getByRole('slider', { name: 'Daily' })
98
+ for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
99
+ }
100
+
101
+ test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
102
+ renderPair('leading')
103
+ driveDailyToAug05()
104
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
105
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
106
+ })
107
+
108
+ test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
109
+ renderPair()
110
+ driveDailyToAug05()
111
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
112
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
113
+ })
114
+ })
@@ -1,6 +1,7 @@
1
1
  import { curveMonotoneX } from '@visx/curve'
2
2
  import { AreaStack } from '@visx/shape'
3
3
  import { memo, useCallback, useMemo } from 'react'
4
+ import type { CursorResolution } from '../cursor/resolve'
4
5
  import { CartesianChart } from '../primitives/CartesianChart'
5
6
  import type { AxisConfig, PlotContext } from '../primitives/CartesianChart'
6
7
  import type { ChartLegendConfig, ChartSeries } from '../series'
@@ -21,6 +22,14 @@ export type StackedAreaProps<T> = {
21
22
  y?: AxisConfig<T>
22
23
  /** X tick count override. Default: as many as fit. */
23
24
  xTicks?: number
25
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
26
+ formatX?: (key: string) => string
27
+ /**
28
+ * How a sibling chart's broadcast cursor key resolves against this chart's points. Default
29
+ * `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
30
+ * keyed by its Monday) — see `CursorResolution`.
31
+ */
32
+ cursorResolution?: CursorResolution
24
33
  /** Legend config forwarded to `CartesianChart`; `false` disables the legend (sparkline escape).
25
34
  * Default `{ placement: 'bottom' }`. */
26
35
  legend?: ChartLegendConfig | false
@@ -44,7 +53,20 @@ export type StackedAreaProps<T> = {
44
53
  * to restore the original bottom-to-top stacking order for `AreaStack`'s `keys`.
45
54
  */
46
55
  function StackedAreaInner<T>(props: StackedAreaProps<T>) {
47
- const { data, chartId, getX, series, y, xTicks, height, legend, ariaLabel, isPending } = props
56
+ const {
57
+ data,
58
+ chartId,
59
+ getX,
60
+ series,
61
+ y,
62
+ xTicks,
63
+ formatX,
64
+ cursorResolution,
65
+ height,
66
+ legend,
67
+ ariaLabel,
68
+ isPending,
69
+ } = props
48
70
 
49
71
  // The plotted quantity is the CUMULATIVE stack top, not any one series' value — the built-in
50
72
  // 'auto' domain (per-series max) would clip a multi-band stack. Skipped when the caller already
@@ -99,6 +121,8 @@ function StackedAreaInner<T>(props: StackedAreaProps<T>) {
99
121
  y={yConfig}
100
122
  cursorValue={stackedCursorValue}
101
123
  {...(xTicks !== undefined && { xTicks })}
124
+ {...(formatX !== undefined && { formatX })}
125
+ {...(cursorResolution !== undefined && { cursorResolution })}
102
126
  {...(height !== undefined && { height })}
103
127
  {...(legend !== undefined && { legend })}
104
128
  {...(ariaLabel !== undefined && { ariaLabel })}
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `ZonedLine` — the `strokeOpacity` wiring: `primary.strokeOpacity ?? 1`. SSR harness (no hover
3
+ * needed), same pattern as `CartesianChart.test.tsx`'s `nice` assertions.
4
+ */
5
+ import { fireEvent, render, screen } from '@testing-library/react'
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { renderToStaticMarkup } from 'react-dom/server'
8
+ import { ChartCursorScope } from '../cursor/scope'
9
+ import { CartesianChart } from '../primitives/CartesianChart'
10
+ import { ZonedLine } from './ZonedLine'
11
+ import type { ChartSeries } from '../series'
12
+
13
+ type Row = { date: string; v: number }
14
+
15
+ const rows: Row[] = [
16
+ { date: '2026-08-01', v: 10 },
17
+ { date: '2026-08-02', v: 12 },
18
+ ]
19
+
20
+ describe('ZonedLine — series.strokeOpacity dims the plotted stroke', () => {
21
+ test('the line path carries the configured stroke-opacity attribute', () => {
22
+ const series: ChartSeries<Row>[] = [
23
+ {
24
+ key: 'v',
25
+ label: 'V',
26
+ color: '#654321',
27
+ mark: 'line',
28
+ getValue: (d) => d.v,
29
+ strokeOpacity: 0.5,
30
+ },
31
+ ]
32
+
33
+ const html = renderToStaticMarkup(
34
+ <ZonedLine<Row> data={rows} chartId="zl-opacity" getX={(d) => d.date} series={series} />,
35
+ )
36
+
37
+ expect(html).toContain('stroke="#654321"')
38
+ expect(html).toContain('stroke-opacity="0.5"')
39
+ })
40
+ })
41
+
42
+ describe('ZonedLine — formatX', () => {
43
+ test('a custom formatX renders on the bottom axis instead of the default DD.MM', () => {
44
+ const series: ChartSeries<Row>[] = [
45
+ { key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
46
+ ]
47
+ const html = renderToStaticMarkup(
48
+ <ZonedLine<Row>
49
+ data={rows}
50
+ chartId="zl-formatx"
51
+ getX={(d) => d.date}
52
+ series={series}
53
+ formatX={(key) => `X:${key}`}
54
+ />,
55
+ )
56
+ expect(html).toContain('X:2026-08-01')
57
+ expect(html).not.toContain('>01.08<')
58
+ })
59
+ })
60
+
61
+ describe('ZonedLine — tooltip.formatHeader', () => {
62
+ test('overrides the tooltip header date text', async () => {
63
+ const series: ChartSeries<Row>[] = [
64
+ { key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
65
+ ]
66
+ render(
67
+ <ZonedLine<Row>
68
+ data={rows}
69
+ chartId="zl-formatheader"
70
+ getX={(d) => d.date}
71
+ series={series}
72
+ tooltip={{ formatHeader: (key) => `hdr:${key}` }}
73
+ />,
74
+ )
75
+
76
+ const slider = screen.getByRole('slider')
77
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
78
+
79
+ expect(await screen.findByText('hdr:2026-08-01')).toBeTruthy()
80
+ })
81
+ })
82
+
83
+ describe('ZonedLine — cursorResolution threads through to sibling resolution', () => {
84
+ // Daily calendar Aug 01–14 (a plain CartesianChart driver) alongside a ZonedLine sibling folded
85
+ // into 2 weekly buckets keyed by each week's leading day (Aug 01, Aug 08) — parity with
86
+ // `Bars.test.tsx`'s equivalent block. Proves the prop reaches THIS kind, not just
87
+ // `CartesianChart` (a kind dropping the forward would silently fall back to 'nearest').
88
+ const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
89
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
90
+ v: i,
91
+ }))
92
+ const foldedRows: Row[] = [
93
+ { date: '2026-08-01', v: 0 },
94
+ { date: '2026-08-08', v: 1 },
95
+ ]
96
+ const dailySeries: ChartSeries<Row>[] = [
97
+ { key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
98
+ ]
99
+ const foldedSeries: ChartSeries<Row>[] = [
100
+ { key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
101
+ ]
102
+
103
+ function renderPair(cursorResolution?: 'leading') {
104
+ render(
105
+ <ChartCursorScope>
106
+ <CartesianChart<Row>
107
+ data={dailyRows}
108
+ chartId="zl-daily"
109
+ getX={(d) => d.date}
110
+ series={dailySeries}
111
+ ariaLabel="Daily"
112
+ >
113
+ {() => null}
114
+ </CartesianChart>
115
+ <ZonedLine<Row>
116
+ data={foldedRows}
117
+ chartId="zl-folded"
118
+ getX={(d) => d.date}
119
+ series={foldedSeries}
120
+ ariaLabel="Folded"
121
+ {...(cursorResolution !== undefined && { cursorResolution })}
122
+ />
123
+ </ChartCursorScope>,
124
+ )
125
+ }
126
+
127
+ // Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
128
+ // via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
129
+ function driveDailyToAug05() {
130
+ const dailySlider = screen.getByRole('slider', { name: 'Daily' })
131
+ for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
132
+ }
133
+
134
+ test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
135
+ renderPair('leading')
136
+ driveDailyToAug05()
137
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
138
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
139
+ })
140
+
141
+ test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
142
+ renderPair()
143
+ driveDailyToAug05()
144
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
145
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
146
+ })
147
+ })
@@ -2,6 +2,7 @@ import { curveMonotoneX } from '@visx/curve'
2
2
  import { AreaClosed, LinePath } from '@visx/shape'
3
3
  import { Threshold } from '@visx/threshold'
4
4
  import { memo, useMemo } from 'react'
5
+ import type { CursorResolution } from '../cursor/resolve'
5
6
  import { AreaGradient, areaFillUrl } from '../primitives/AreaGradient'
6
7
  import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
7
8
  import { CartesianChart } from '../primitives/CartesianChart'
@@ -51,6 +52,14 @@ export type ZonedLineProps<T> = {
51
52
  refLines?: ZonedLineRefLine[]
52
53
  /** Exact number of x ticks. Default: as many as fit. */
53
54
  xTicks?: number
55
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
56
+ formatX?: (key: string) => string
57
+ /**
58
+ * How a sibling chart's broadcast cursor key resolves against this chart's points. Default
59
+ * `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
60
+ * keyed by its Monday) — see `CursorResolution`.
61
+ */
62
+ cursorResolution?: CursorResolution
54
63
  /** Tooltip config — `label` for a right-aligned header badge (e.g. zone name with zone color),
55
64
  * `extraRows` for rows appended after the derived row. `false` disables the tooltip entirely. */
56
65
  tooltip?: CartesianTooltipConfig<T> | false
@@ -110,6 +119,8 @@ function ZonedLineInner<T>(props: ZonedLineProps<T>) {
110
119
  thresholds,
111
120
  refLines,
112
121
  xTicks,
122
+ formatX,
123
+ cursorResolution,
113
124
  tooltip,
114
125
  areaFill,
115
126
  height,
@@ -140,6 +151,8 @@ function ZonedLineInner<T>(props: ZonedLineProps<T>) {
140
151
  {...(xZones !== undefined && { xZones })}
141
152
  {...(refLines !== undefined && { refLines })}
142
153
  {...(xTicks !== undefined && { xTicks })}
154
+ {...(formatX !== undefined && { formatX })}
155
+ {...(cursorResolution !== undefined && { cursorResolution })}
143
156
  {...(tooltip !== undefined && { tooltip })}
144
157
  {...(height !== undefined && { height })}
145
158
  {...(legend !== undefined && { legend })}
@@ -228,6 +241,7 @@ function ZonedLineMarks<T>({
228
241
  stroke={primary.color}
229
242
  strokeWidth={primary.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
230
243
  strokeDasharray={primary.dash === 'dashed' ? VX.dashArray : undefined}
244
+ strokeOpacity={primary.strokeOpacity ?? 1}
231
245
  curve={curveMonotoneX}
232
246
  />
233
247
  )}
@@ -0,0 +1,269 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
+ import { describe, expect, test } from 'bun:test'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+ import { ChartCursorScope } from '../cursor/scope'
5
+ import type { ChartSeries } from '../series'
6
+ import { CartesianChart, resolveAxisDomain } from './CartesianChart'
7
+
8
+ type Row = { date: string; a: number; b: number }
9
+
10
+ const rows: Row[] = [
11
+ { date: '2026-08-01', a: 10, b: 4 },
12
+ { date: '2026-08-02', a: 40, b: 6 },
13
+ { date: '2026-08-03', a: 25, b: 5 },
14
+ ]
15
+
16
+ const seriesFor = (key: 'a' | 'b'): ChartSeries<Row> => ({
17
+ key,
18
+ label: key,
19
+ color: '#000',
20
+ mark: 'line',
21
+ getValue: (d) => d[key],
22
+ })
23
+
24
+ const both = [seriesFor('a'), seriesFor('b')]
25
+
26
+ describe('resolveAxisDomain', () => {
27
+ test('a fixed tuple passes through untouched', () => {
28
+ expect(resolveAxisDomain({ domain: [0, 100] }, rows, both)).toEqual([0, 100])
29
+ })
30
+
31
+ test('auto pads the upper bound away from zero and keeps the zero baseline', () => {
32
+ const [min, max] = resolveAxisDomain(undefined, rows, both)
33
+ expect(min).toBe(0)
34
+ expect(max).toBeCloseTo(44, 5)
35
+ })
36
+
37
+ test('auto over an ALL-NEGATIVE series keeps the largest value inside the plot', () => {
38
+ // `max * pad` would push the upper bound to -5.5, i.e. below the largest datum, clipping it.
39
+ const negative: ChartSeries<Row>[] = [
40
+ { ...seriesFor('a'), getValue: (d) => -d.a },
41
+ { ...seriesFor('b'), getValue: (d) => -d.b },
42
+ ]
43
+ const [min, max] = resolveAxisDomain({ autoMinCeil: Infinity }, rows, negative)
44
+ expect(max).toBeGreaterThanOrEqual(-4)
45
+ expect(min).toBeLessThanOrEqual(-40)
46
+ })
47
+
48
+ test('autoMaxFloor raises a low upper bound, then pads it (mirrors autoMinCeil)', () => {
49
+ // The floor clamps the RAW upper bound first (44 -> 500), padding applies after: 500 * 1.1.
50
+ const [, max] = resolveAxisDomain({ autoMaxFloor: 500 }, rows, both)
51
+ expect(max).toBeCloseTo(550, 5)
52
+ })
53
+
54
+ test('autoMaxFloor at exactly the padded case (dataMax 3.2, pad 1.1, floor 6) yields 6.6', () => {
55
+ const single: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 3.2 }]
56
+ const [, max] = resolveAxisDomain({ autoMaxFloor: 6, autoPad: 1.1 }, rows, single)
57
+ expect(max).toBeCloseTo(6.6, 5)
58
+ })
59
+
60
+ test('autoMaxFloor and autoMinCeil pad symmetrically — both clamp first, pad second', () => {
61
+ // Upper: floor 6 clamps 3.2 -> 6, then pads to 6.6 (multiply, away from zero).
62
+ const positive: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 3.2 }]
63
+ const [, max] = resolveAxisDomain({ autoMaxFloor: 6, autoPad: 1.1 }, rows, positive)
64
+ expect(max).toBeCloseTo(6.6, 5)
65
+ // Lower: ceil -6 clamps -3.2 -> -6, then pads to -6.6 (multiply, away from zero) — the mirror.
66
+ const negative: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => -3.2 }]
67
+ const [min] = resolveAxisDomain({ autoMinCeil: -6, autoPad: 1.1 }, rows, negative)
68
+ expect(min).toBeCloseTo(-6.6, 5)
69
+ })
70
+
71
+ test('the domain follows the VISIBLE series — hiding the tall one shrinks the axis', () => {
72
+ const [, withBoth] = resolveAxisDomain(undefined, rows, both)
73
+ const [, onlyB] = resolveAxisDomain(undefined, rows, [seriesFor('b')])
74
+ expect(onlyB).toBeLessThan(withBoth)
75
+ })
76
+
77
+ test('a domain function receives the data AND the visible series', () => {
78
+ const domain = resolveAxisDomain(
79
+ { domain: (data, visible) => [0, data.length * visible.length] },
80
+ rows,
81
+ both,
82
+ )
83
+ expect(domain).toEqual([0, 6])
84
+ })
85
+
86
+ test('empty data falls back to a usable unit domain', () => {
87
+ expect(resolveAxisDomain(undefined, [], both)).toEqual([0, 1])
88
+ })
89
+ })
90
+
91
+ describe('resolveAxisDomain — autoMaxFloor composed with an all-negative series', () => {
92
+ // getValue -> -d.a - 20 over rows a=[10, 40, 25] yields [-30, -60, -45]: data max is -30, data
93
+ // min is -60 — deliberately NOT a flat series, so this exercises the real clamp-then-pad path
94
+ // rather than the degenerate flat-collapse branch covered separately below.
95
+ const allNegative: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: (d) => -d.a - 20 }]
96
+
97
+ test('a floor above the data max clamps to the floor, then pads TOWARD zero', () => {
98
+ const [, max] = resolveAxisDomain({ autoMaxFloor: -10 }, rows, allNegative)
99
+ // clamp: Math.max(-30, -10) = -10; padAutoUpper divides for a negative candidate (toward zero).
100
+ expect(max).toBeCloseTo(-10 / 1.1, 10)
101
+ })
102
+
103
+ test('without a floor, the existing all-negative behavior is unchanged', () => {
104
+ const [, max] = resolveAxisDomain(undefined, rows, allNegative)
105
+ // no floor -> candidate stays the raw data max, -30.
106
+ expect(max).toBeCloseTo(-30 / 1.1, 10)
107
+ })
108
+
109
+ test('autoMaxFloor / autoMinCeil clamp-then-pad symmetrically through resolveAxisDomain', () => {
110
+ // Mirror series: same magnitudes, positive instead of negative (data min 30, not data max -30).
111
+ const allPositive: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: (d) => d.a + 20 }]
112
+ const [, maxFromFloor] = resolveAxisDomain({ autoMaxFloor: -10 }, rows, allNegative)
113
+ const [minFromCeil] = resolveAxisDomain({ autoMinCeil: 10 }, rows, allPositive)
114
+ expect(maxFromFloor).toBeCloseTo(-minFromCeil, 10)
115
+ })
116
+ })
117
+
118
+ describe('resolveAxisDomain — overlay bounds', () => {
119
+ test('a zone past the data stretches the axis instead of clipping', () => {
120
+ const [, max] = resolveAxisDomain(undefined, rows, both, [0, 200])
121
+ expect(max).toBeGreaterThanOrEqual(200)
122
+ })
123
+
124
+ test('a reference line below the data lowers the floor', () => {
125
+ const [min] = resolveAxisDomain({ autoMinCeil: Infinity }, rows, both, [-30])
126
+ expect(min).toBeLessThanOrEqual(-30)
127
+ })
128
+
129
+ test('infinite bounds ("top/bottom of axis") are ignored, not blown up', () => {
130
+ const plain = resolveAxisDomain(undefined, rows, both)
131
+ expect(resolveAxisDomain(undefined, rows, both, [-Infinity, Infinity])).toEqual(plain)
132
+ })
133
+ })
134
+
135
+ describe('resolveAxisDomain — degenerate input', () => {
136
+ test('a flat all-zero series still gets a usable axis, not a zero-extent scale', () => {
137
+ const flat: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 0 }]
138
+ const [min, max] = resolveAxisDomain(undefined, rows, flat)
139
+ expect(max).toBeGreaterThan(min)
140
+ })
141
+
142
+ test('a flat non-zero series is not collapsed onto its own value', () => {
143
+ const flat: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 7 }]
144
+ const [min, max] = resolveAxisDomain({ autoMinCeil: Infinity }, rows, flat)
145
+ expect(max).toBeGreaterThan(min)
146
+ })
147
+ })
148
+
149
+ describe('AxisConfig.nice — threaded to BOTH the probe and the real scale', () => {
150
+ // A fixed, deliberately non-round domain: d3's `.ticks()` never produces a tick past the raw
151
+ // domain max (95.7) without `nice`, but WITH `nice` the scale first rounds its domain outward
152
+ // (e.g. to 100), so a tick at "100" can only appear on the `nice: true` render.
153
+ function renderChart(nice: boolean): string {
154
+ return renderToStaticMarkup(
155
+ <CartesianChart<Row>
156
+ data={rows}
157
+ chartId="nice-test"
158
+ getX={(d) => d.date}
159
+ series={[seriesFor('a')]}
160
+ y={{ domain: [0, 95.7], ticks: 5, format: (v) => String(v), nice }}
161
+ >
162
+ {() => null}
163
+ </CartesianChart>,
164
+ )
165
+ }
166
+
167
+ test('nice: false never paints a tick past the raw domain max', () => {
168
+ expect(renderChart(false)).not.toContain('>100<')
169
+ })
170
+
171
+ test('nice: true rounds the scale outward — a tick beyond the raw domain max appears', () => {
172
+ expect(renderChart(true)).toContain('>100<')
173
+ })
174
+ })
175
+
176
+ describe('cursorResolution — threads through to sibling resolution (CartesianChart)', () => {
177
+ // Daily calendar Aug 01–14, plus a sibling folded into 2 weekly buckets keyed by each week's
178
+ // leading day (Aug 01, Aug 08) — the exact shape of the playground's "Weekly digest" pairing.
179
+ const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
180
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
181
+ a: i,
182
+ b: i,
183
+ }))
184
+ const foldedRows: Row[] = [
185
+ { date: '2026-08-01', a: 0, b: 0 },
186
+ { date: '2026-08-08', a: 1, b: 1 },
187
+ ]
188
+
189
+ function renderPair(cursorResolution?: 'leading') {
190
+ render(
191
+ <ChartCursorScope>
192
+ <CartesianChart<Row>
193
+ data={dailyRows}
194
+ chartId="daily"
195
+ getX={(d) => d.date}
196
+ series={[seriesFor('a')]}
197
+ ariaLabel="Daily"
198
+ >
199
+ {() => null}
200
+ </CartesianChart>
201
+ <CartesianChart<Row>
202
+ data={foldedRows}
203
+ chartId="folded"
204
+ getX={(d) => d.date}
205
+ series={[seriesFor('a')]}
206
+ ariaLabel="Folded"
207
+ {...(cursorResolution !== undefined && { cursorResolution })}
208
+ >
209
+ {() => null}
210
+ </CartesianChart>
211
+ </ChartCursorScope>,
212
+ )
213
+ }
214
+
215
+ // Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (its midpoint is
216
+ // Aug 04) — via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
217
+ function driveDailyToAug05() {
218
+ const dailySlider = screen.getByRole('slider', { name: 'Daily' })
219
+ for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
220
+ }
221
+
222
+ test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
223
+ renderPair('leading')
224
+ driveDailyToAug05()
225
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
226
+ // Aug 05 is inside the Aug01-07 bucket — 'leading' must resolve to its leading key, Aug 01.
227
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
228
+ })
229
+
230
+ test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket — proves cursorResolution actually threads through, not just the resolver it wraps", () => {
231
+ renderPair()
232
+ driveDailyToAug05()
233
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
234
+ // Aug 05 is closer to Aug 08 (distance 3) than Aug 01 (distance 4) — 'nearest' picks Aug 08.
235
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
236
+ })
237
+ })
238
+
239
+ describe('tooltip extraRows — ctx.visible/ctx.hidden track legend toggling', () => {
240
+ test('toggling a series off via its legend entry updates the ctx the row reads', async () => {
241
+ render(
242
+ <CartesianChart<Row>
243
+ data={rows}
244
+ chartId="extra-rows-ctx"
245
+ getX={(d) => d.date}
246
+ series={both}
247
+ tooltip={{
248
+ extraRows: (_d, ctx) => (
249
+ <div data-testid="extra-ctx">
250
+ visible:{ctx.visible.map((s) => s.key).join(',')}|hidden:
251
+ {[...ctx.hidden].join(',')}
252
+ </div>
253
+ ),
254
+ }}
255
+ >
256
+ {() => null}
257
+ </CartesianChart>,
258
+ )
259
+
260
+ const slider = screen.getByRole('slider')
261
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
262
+
263
+ expect((await screen.findByTestId('extra-ctx')).textContent).toContain('visible:a,b|hidden:')
264
+
265
+ fireEvent.click(screen.getByRole('button', { name: 'b' }))
266
+
267
+ expect((await screen.findByTestId('extra-ctx')).textContent).toContain('visible:a|hidden:b')
268
+ })
269
+ })