basalt-ui 1.16.0 → 1.18.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 (69) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/agent/rules/basalt-charts.md +58 -4
  3. package/agent/skills/basalt-design/SKILL.md +2 -2
  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 +1 -0
  11. package/dist/charts/index.d.ts.map +1 -1
  12. package/dist/charts/kinds/Bars.d.ts +9 -0
  13. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  14. package/dist/charts/kinds/Bars.js +4 -0
  15. package/dist/charts/kinds/DualPanel.d.ts +24 -0
  16. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  17. package/dist/charts/kinds/DualPanel.js +39 -9
  18. package/dist/charts/kinds/MultiLine.d.ts +9 -0
  19. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  20. package/dist/charts/kinds/MultiLine.js +10 -8
  21. package/dist/charts/kinds/StackedArea.d.ts +9 -0
  22. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  23. package/dist/charts/kinds/StackedArea.js +16 -1
  24. package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
  25. package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
  26. package/dist/charts/kinds/ZonedLine.d.ts +9 -0
  27. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  28. package/dist/charts/kinds/ZonedLine.js +4 -0
  29. package/dist/charts/primitives/CartesianChart.d.ts +40 -2
  30. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  31. package/dist/charts/primitives/CartesianChart.js +16 -6
  32. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  33. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  34. package/dist/charts/primitives/ChartFrame.js +1 -1
  35. package/dist/charts/primitives/ChartTooltip.d.ts +12 -2
  36. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  37. package/dist/charts/primitives/ChartTooltip.js +5 -3
  38. package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
  39. package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
  40. package/dist/charts/series.d.ts +9 -1
  41. package/dist/charts/series.d.ts.map +1 -1
  42. package/dist/charts/utils/domain.d.ts +15 -0
  43. package/dist/charts/utils/domain.d.ts.map +1 -1
  44. package/dist/charts/utils/domain.js +6 -1
  45. package/llms.txt +2 -2
  46. package/package.json +1 -1
  47. package/src/charts/cursor/resolve.test.ts +62 -0
  48. package/src/charts/cursor/resolve.ts +71 -7
  49. package/src/charts/hooks/useChartCursor.ts +12 -1
  50. package/src/charts/index.ts +1 -0
  51. package/src/charts/kinds/Bars.test.tsx +85 -0
  52. package/src/charts/kinds/Bars.tsx +13 -0
  53. package/src/charts/kinds/DualPanel.test.tsx +391 -1
  54. package/src/charts/kinds/DualPanel.tsx +74 -8
  55. package/src/charts/kinds/MultiLine.test.tsx +181 -0
  56. package/src/charts/kinds/MultiLine.tsx +19 -8
  57. package/src/charts/kinds/StackedArea.test.tsx +114 -0
  58. package/src/charts/kinds/StackedArea.tsx +25 -1
  59. package/src/charts/kinds/ZonedLine.test.tsx +110 -0
  60. package/src/charts/kinds/ZonedLine.tsx +13 -0
  61. package/src/charts/primitives/CartesianChart.test.tsx +284 -3
  62. package/src/charts/primitives/CartesianChart.tsx +64 -12
  63. package/src/charts/primitives/ChartFrame.test.tsx +42 -0
  64. package/src/charts/primitives/ChartFrame.tsx +9 -2
  65. package/src/charts/primitives/ChartTooltip.test.tsx +44 -0
  66. package/src/charts/primitives/ChartTooltip.tsx +14 -2
  67. package/src/charts/series.ts +8 -2
  68. package/src/charts/utils/domain.test.ts +31 -1
  69. package/src/charts/utils/domain.ts +19 -0
@@ -2,8 +2,11 @@
2
2
  * `MultiLine` — the `strokeOpacity` wiring: `dimOpacity(s) * (s.strokeOpacity ?? 1)`. SSR harness
3
3
  * (no hover needed), same pattern as `CartesianChart.test.tsx`'s `nice` assertions.
4
4
  */
5
+ import { fireEvent, render, screen } from '@testing-library/react'
5
6
  import { describe, expect, test } from 'bun:test'
6
7
  import { renderToStaticMarkup } from 'react-dom/server'
8
+ import { ChartCursorScope } from '../cursor/scope'
9
+ import { CartesianChart } from '../primitives/CartesianChart'
7
10
  import { MultiLine } from './MultiLine'
8
11
  import type { ChartSeries } from '../series'
9
12
 
@@ -35,3 +38,181 @@ describe('MultiLine — series.strokeOpacity dims the plotted stroke', () => {
35
38
  expect(html).toContain('stroke-opacity="0.35"')
36
39
  })
37
40
  })
41
+
42
+ describe('MultiLine — 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
+ <MultiLine<Row>
49
+ data={rows}
50
+ chartId="ml-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('MultiLine — getMarker fillOpacity/ring', () => {
62
+ test('ring: false omits the stroke; fillOpacity defaults 1 and honors an override', () => {
63
+ const series: ChartSeries<Row>[] = [
64
+ {
65
+ key: 'v',
66
+ label: 'V',
67
+ color: '#111',
68
+ mark: 'line',
69
+ getValue: (d) => d.v,
70
+ getMarker: (d) => (d.date === '2026-08-01' ? { ring: false, fillOpacity: 0.7 } : null),
71
+ },
72
+ ]
73
+ const html = renderToStaticMarkup(
74
+ <MultiLine<Row> data={rows} chartId="ml-marker-ring" getX={(d) => d.date} series={series} />,
75
+ )
76
+ expect(html).toContain('fill-opacity="0.7"')
77
+ expect(html).not.toContain('stroke-width="2"')
78
+ })
79
+
80
+ test('default marker keeps the punched-out ring and full opacity (unchanged rendering)', () => {
81
+ const series: ChartSeries<Row>[] = [
82
+ {
83
+ key: 'v',
84
+ label: 'V',
85
+ color: '#111',
86
+ mark: 'line',
87
+ getValue: (d) => d.v,
88
+ getMarker: (d) => (d.date === '2026-08-01' ? {} : null),
89
+ },
90
+ ]
91
+ const html = renderToStaticMarkup(
92
+ <MultiLine<Row>
93
+ data={rows}
94
+ chartId="ml-marker-default"
95
+ getX={(d) => d.date}
96
+ series={series}
97
+ />,
98
+ )
99
+ expect(html).toContain('stroke-width="2"')
100
+ expect(html).toContain('fill-opacity="1"')
101
+ })
102
+ })
103
+
104
+ describe('MultiLine — markerShape="star" honours getMarker ring/fillOpacity', () => {
105
+ test('ring: false omits the stroke; fillOpacity defaults 1 and honors an override', () => {
106
+ const series: ChartSeries<Row>[] = [
107
+ {
108
+ key: 'v',
109
+ label: 'V',
110
+ color: '#111',
111
+ mark: 'line',
112
+ getValue: (d) => d.v,
113
+ getMarker: (d) => (d.date === '2026-08-01' ? { ring: false, fillOpacity: 0.7 } : null),
114
+ },
115
+ ]
116
+ const html = renderToStaticMarkup(
117
+ <MultiLine<Row>
118
+ data={rows}
119
+ chartId="ml-star-ring"
120
+ getX={(d) => d.date}
121
+ series={series}
122
+ markerShape="star"
123
+ />,
124
+ )
125
+ expect(html).toContain('fill-opacity="0.7"')
126
+ expect(html).not.toContain('stroke-width="1.5"')
127
+ })
128
+
129
+ test('default marker keeps the punched-out ring and full opacity (unchanged rendering)', () => {
130
+ const series: ChartSeries<Row>[] = [
131
+ {
132
+ key: 'v',
133
+ label: 'V',
134
+ color: '#111',
135
+ mark: 'line',
136
+ getValue: (d) => d.v,
137
+ getMarker: (d) => (d.date === '2026-08-01' ? {} : null),
138
+ },
139
+ ]
140
+ const html = renderToStaticMarkup(
141
+ <MultiLine<Row>
142
+ data={rows}
143
+ chartId="ml-star-default"
144
+ getX={(d) => d.date}
145
+ series={series}
146
+ markerShape="star"
147
+ />,
148
+ )
149
+ expect(html).toContain('stroke-width="1.5"')
150
+ expect(html).toContain('fill-opacity="1"')
151
+ })
152
+ })
153
+
154
+ describe('MultiLine — cursorResolution threads through to sibling resolution', () => {
155
+ // Daily calendar Aug 01–14 (a plain CartesianChart driver) alongside a MultiLine sibling folded
156
+ // into 2 weekly buckets keyed by each week's leading day (Aug 01, Aug 08) — parity with
157
+ // `Bars.test.tsx`'s equivalent block. Proves the prop reaches THIS kind, not just
158
+ // `CartesianChart` (a kind dropping the forward would silently fall back to 'nearest').
159
+ const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
160
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
161
+ v: i,
162
+ }))
163
+ const foldedRows: Row[] = [
164
+ { date: '2026-08-01', v: 0 },
165
+ { date: '2026-08-08', v: 1 },
166
+ ]
167
+ const dailySeries: ChartSeries<Row>[] = [
168
+ { key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
169
+ ]
170
+ const foldedSeries: ChartSeries<Row>[] = [
171
+ { key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
172
+ ]
173
+
174
+ function renderPair(cursorResolution?: 'leading') {
175
+ render(
176
+ <ChartCursorScope>
177
+ <CartesianChart<Row>
178
+ data={dailyRows}
179
+ chartId="ml-daily"
180
+ getX={(d) => d.date}
181
+ series={dailySeries}
182
+ ariaLabel="Daily"
183
+ >
184
+ {() => null}
185
+ </CartesianChart>
186
+ <MultiLine<Row>
187
+ data={foldedRows}
188
+ chartId="ml-folded"
189
+ getX={(d) => d.date}
190
+ series={foldedSeries}
191
+ ariaLabel="Folded"
192
+ {...(cursorResolution !== undefined && { cursorResolution })}
193
+ />
194
+ </ChartCursorScope>,
195
+ )
196
+ }
197
+
198
+ // Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
199
+ // via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
200
+ function driveDailyToAug05() {
201
+ const dailySlider = screen.getByRole('slider', { name: 'Daily' })
202
+ for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
203
+ }
204
+
205
+ test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
206
+ renderPair('leading')
207
+ driveDailyToAug05()
208
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
209
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
210
+ })
211
+
212
+ test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
213
+ renderPair()
214
+ driveDailyToAug05()
215
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
216
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
217
+ })
218
+ })
@@ -2,6 +2,7 @@ import { curveMonotoneX } from '@visx/curve'
2
2
  import { LinePath } from '@visx/shape'
3
3
  import { memo, useCallback, useMemo } from 'react'
4
4
  import type { ReactNode } from 'react'
5
+ import type { CursorResolution } from '../cursor/resolve'
5
6
  import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
6
7
  import { CartesianChart } from '../primitives/CartesianChart'
7
8
  import type { XZoneSpec } from '../primitives/XZoneRects'
@@ -35,6 +36,14 @@ export type MultiLineProps<T> = {
35
36
  refLines?: { value: number; color: string; dashed?: boolean }[]
36
37
  /** Exact number of x ticks. Default: as many as fit. */
37
38
  xTicks?: number
39
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
40
+ formatX?: (key: string) => string
41
+ /**
42
+ * How a sibling chart's broadcast cursor key resolves against this chart's points. Default
43
+ * `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
44
+ * keyed by its Monday) — see `CursorResolution`.
45
+ */
46
+ cursorResolution?: CursorResolution
38
47
  /** Tooltip config — `label` for a right-aligned header badge, `extraRows` for rows appended
39
48
  * after the derived per-series rows. `false` disables the tooltip entirely. */
40
49
  tooltip?: CartesianTooltipConfig<T> | false
@@ -100,6 +109,8 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
100
109
  xZones,
101
110
  refLines,
102
111
  xTicks,
112
+ formatX,
113
+ cursorResolution,
103
114
  tooltip,
104
115
  markerShape = 'circle',
105
116
  height,
@@ -127,6 +138,8 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
127
138
  {...(xZones !== undefined && { xZones })}
128
139
  {...(refLines !== undefined && { refLines })}
129
140
  {...(xTicks !== undefined && { xTicks })}
141
+ {...(formatX !== undefined && { formatX })}
142
+ {...(cursorResolution !== undefined && { cursorResolution })}
130
143
  {...(tooltip !== undefined && { tooltip })}
131
144
  {...(height !== undefined && { height })}
132
145
  {...(legend !== undefined && { legend })}
@@ -196,16 +209,16 @@ function MultiLineMarks<T>({
196
209
  const cy = scale(p.__y)
197
210
  const color = m.color ?? s.color
198
211
  const r = m.r ?? (markerShape === 'star' ? STAR_R : VX.dotR)
212
+ const ring = m.ring ?? true
213
+ const fillOpacity = (m.fillOpacity ?? 1) * op
199
214
  markers.push(
200
215
  markerShape === 'star' ? (
201
216
  <path
202
217
  key={`mk-${s.key}-${getX(p.__d)}`}
203
218
  d={starPath(cx, cy, r)}
204
219
  fill={color}
205
- stroke={VX.dotStroke}
206
- strokeWidth={1.5}
207
- fillOpacity={op}
208
- strokeOpacity={op}
220
+ fillOpacity={fillOpacity}
221
+ {...(ring && { stroke: VX.dotStroke, strokeWidth: 1.5, strokeOpacity: op })}
209
222
  />
210
223
  ) : (
211
224
  <circle
@@ -214,10 +227,8 @@ function MultiLineMarks<T>({
214
227
  cy={cy}
215
228
  r={r}
216
229
  fill={color}
217
- stroke={VX.dotStroke}
218
- strokeWidth={2}
219
- fillOpacity={op}
220
- strokeOpacity={op}
230
+ fillOpacity={fillOpacity}
231
+ {...(ring && { stroke: VX.dotStroke, strokeWidth: 2, strokeOpacity: op })}
221
232
  />
222
233
  ),
223
234
  )
@@ -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 })}
@@ -2,8 +2,11 @@
2
2
  * `ZonedLine` — the `strokeOpacity` wiring: `primary.strokeOpacity ?? 1`. SSR harness (no hover
3
3
  * needed), same pattern as `CartesianChart.test.tsx`'s `nice` assertions.
4
4
  */
5
+ import { fireEvent, render, screen } from '@testing-library/react'
5
6
  import { describe, expect, test } from 'bun:test'
6
7
  import { renderToStaticMarkup } from 'react-dom/server'
8
+ import { ChartCursorScope } from '../cursor/scope'
9
+ import { CartesianChart } from '../primitives/CartesianChart'
7
10
  import { ZonedLine } from './ZonedLine'
8
11
  import type { ChartSeries } from '../series'
9
12
 
@@ -35,3 +38,110 @@ describe('ZonedLine — series.strokeOpacity dims the plotted stroke', () => {
35
38
  expect(html).toContain('stroke-opacity="0.5"')
36
39
  })
37
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 })}