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
@@ -6,9 +6,11 @@
6
6
  * `Bars.test.tsx`) — DualPanel has two `HoverOverlay`s sharing one cursor, but only the TOP one is
7
7
  * keyboard-focusable, so `getByRole('slider')` is unambiguous.
8
8
  */
9
- import { fireEvent, render, screen } from '@testing-library/react'
9
+ import { fireEvent, render, screen, within } from '@testing-library/react'
10
10
  import { describe, expect, test } from 'bun:test'
11
11
  import { renderToStaticMarkup } from 'react-dom/server'
12
+ import { ChartCursorScope } from '../cursor/scope'
13
+ import { CartesianChart } from '../primitives/CartesianChart'
12
14
  import { DualPanel } from './DualPanel'
13
15
  import type { ChartSeries } from '../series'
14
16
 
@@ -96,6 +98,236 @@ describe('DualPanel — per-point getMarker rendering', () => {
96
98
  })
97
99
  })
98
100
 
101
+ describe('DualPanel — getMarker fillOpacity/ring', () => {
102
+ test('ring: false omits the stroke; fillOpacity defaults 1 and honors an override', () => {
103
+ const markerRows: Row[] = [
104
+ { date: '2026-08-01', v: 10, bar: 0.02 },
105
+ { date: '2026-08-02', v: 12, bar: 0.03 },
106
+ ]
107
+ const markerSeries: ChartSeries<Row>[] = [
108
+ {
109
+ key: 'v',
110
+ label: 'V',
111
+ color: '#111',
112
+ mark: 'line',
113
+ getValue: (d) => d.v,
114
+ getMarker: (d) =>
115
+ d.date === '2026-08-01'
116
+ ? { ring: false, fillOpacity: 0.7 }
117
+ : d.date === '2026-08-02'
118
+ ? {}
119
+ : null,
120
+ },
121
+ ]
122
+
123
+ const { container } = render(
124
+ <DualPanel<Row>
125
+ data={markerRows}
126
+ chartId="dp-marker-ring"
127
+ getX={(d) => d.date}
128
+ series={markerSeries}
129
+ getBar={(d) => d.bar}
130
+ barLabel="Bar"
131
+ barColorPositive="#0a0"
132
+ barColorNegative="#a00"
133
+ formatTop={(v) => String(v)}
134
+ formatBottom={(v) => v.toFixed(2)}
135
+ />,
136
+ )
137
+
138
+ const circles = container.querySelectorAll('circle')
139
+ expect(circles).toHaveLength(2)
140
+ // ring: false — no stroke attribute at all.
141
+ expect(circles[0]?.getAttribute('stroke')).toBeNull()
142
+ expect(circles[0]?.getAttribute('fill-opacity')).toBe('0.7')
143
+ // default marker — unchanged punched-out ring + full opacity.
144
+ expect(circles[1]?.getAttribute('stroke')).not.toBeNull()
145
+ expect(circles[1]?.getAttribute('fill-opacity')).toBe('1')
146
+ })
147
+ })
148
+
149
+ describe('DualPanel — formatX', () => {
150
+ test('a custom formatX renders on the bottom axis instead of the default DD.MM', () => {
151
+ const { container } = render(
152
+ <DualPanel<Row>
153
+ data={rows}
154
+ chartId="dp-formatx"
155
+ getX={(d) => d.date}
156
+ series={series}
157
+ getBar={(d) => d.bar}
158
+ barLabel="Bar"
159
+ barColorPositive="#0a0"
160
+ barColorNegative="#a00"
161
+ formatTop={(v) => String(v)}
162
+ formatBottom={(v) => v.toFixed(2)}
163
+ formatX={(key) => `X:${key}`}
164
+ />,
165
+ )
166
+ expect(container.innerHTML).toContain('X:2026-08-01')
167
+ })
168
+ })
169
+
170
+ describe('DualPanel — formatX reaches the margin measurement, not just the axis', () => {
171
+ test('a formatter that widens x labels narrows the plot rect — the gutter grows to fit it', () => {
172
+ function xLabelGutterWidth(formatX?: (key: string) => string): number {
173
+ const { container, unmount } = render(
174
+ <DualPanel<Row>
175
+ data={rows}
176
+ chartId="dp-margin"
177
+ getX={(d) => d.date}
178
+ series={series}
179
+ getBar={(d) => d.bar}
180
+ barLabel="Bar"
181
+ barColorPositive="#0a0"
182
+ barColorNegative="#a00"
183
+ formatTop={(v) => String(v)}
184
+ formatBottom={(v) => v.toFixed(2)}
185
+ {...(formatX !== undefined && { formatX })}
186
+ />,
187
+ )
188
+ // The transparent HoverOverlay rects are drawn at exactly `width={xMax}` — `plot.width` is
189
+ // fixed under this DOM harness (no ResizeObserver, so `ChartFrame` falls back to its
190
+ // `minWidth` floor), so a narrower `xMax` here can only mean a wider measured margin.
191
+ const overlay = container.querySelector('rect[fill="transparent"]')
192
+ const width = Number(overlay?.getAttribute('width'))
193
+ unmount()
194
+ return width
195
+ }
196
+
197
+ const defaultWidth = xLabelGutterWidth()
198
+ const widenedWidth = xLabelGutterWidth(
199
+ (key) => `Extremely Long Formatted Axis Label ${key} With Lots Of Extra Padding Text`,
200
+ )
201
+ expect(widenedWidth).toBeLessThan(defaultWidth)
202
+ })
203
+ })
204
+
205
+ describe('DualPanel — tooltip.formatHeader', () => {
206
+ test('overrides the tooltip header date text', async () => {
207
+ render(
208
+ <DualPanel<Row>
209
+ data={rows}
210
+ chartId="dp-formatheader"
211
+ getX={(d) => d.date}
212
+ series={series}
213
+ getBar={(d) => d.bar}
214
+ barLabel="Bar"
215
+ barColorPositive="#0a0"
216
+ barColorNegative="#a00"
217
+ formatTop={(v) => String(v)}
218
+ formatBottom={(v) => v.toFixed(2)}
219
+ formatHeader={(key) => `hdr:${key}`}
220
+ />,
221
+ )
222
+
223
+ const slider = screen.getByRole('slider')
224
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
225
+
226
+ expect(await screen.findByText('hdr:2026-08-01')).toBeTruthy()
227
+ })
228
+ })
229
+
230
+ describe('DualPanel — ariaLabel reaches the slider, not only the frame', () => {
231
+ test('both the outer group and the focusable slider carry the name', () => {
232
+ render(
233
+ <DualPanel<Row>
234
+ data={rows}
235
+ chartId="dp-arialabel"
236
+ getX={(d) => d.date}
237
+ series={series}
238
+ getBar={(d) => d.bar}
239
+ barLabel="Bar"
240
+ barColorPositive="#0a0"
241
+ barColorNegative="#a00"
242
+ formatTop={(v) => String(v)}
243
+ formatBottom={(v) => v.toFixed(2)}
244
+ ariaLabel="Latency"
245
+ />,
246
+ )
247
+
248
+ // The bug was precisely that the label stopped at the frame — assert both carry it.
249
+ expect(screen.getByRole('group', { name: 'Latency' })).toBeTruthy()
250
+ expect(screen.getByRole('slider', { name: 'Latency' })).toBeTruthy()
251
+ })
252
+ })
253
+
254
+ describe('DualPanel — slider value attributes', () => {
255
+ test('aria-valuemax is always present; aria-valuenow/valuetext appear only once a point is hovered', () => {
256
+ render(
257
+ <DualPanel<Row>
258
+ data={rows}
259
+ chartId="dp-slider-values"
260
+ getX={(d) => d.date}
261
+ series={series}
262
+ getBar={(d) => d.bar}
263
+ barLabel="Bar"
264
+ barColorPositive="#0a0"
265
+ barColorNegative="#a00"
266
+ formatTop={(v) => String(v)}
267
+ formatBottom={(v) => v.toFixed(2)}
268
+ />,
269
+ )
270
+
271
+ const slider = screen.getByRole('slider')
272
+ expect(slider.getAttribute('aria-valuemax')).toBe('0')
273
+ expect(slider.hasAttribute('aria-valuenow')).toBe(false)
274
+ expect(slider.hasAttribute('aria-valuetext')).toBe(false)
275
+
276
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
277
+
278
+ expect(slider.getAttribute('aria-valuenow')).toBe('0')
279
+ expect(slider.getAttribute('aria-valuetext')).toBe('01.08')
280
+ })
281
+ })
282
+
283
+ describe('DualPanel — aria-valuetext reflects formatX', () => {
284
+ test('a custom formatX also drives the hovered slider value text, not just the axis', () => {
285
+ render(
286
+ <DualPanel<Row>
287
+ data={rows}
288
+ chartId="dp-slider-formatx"
289
+ getX={(d) => d.date}
290
+ series={series}
291
+ getBar={(d) => d.bar}
292
+ barLabel="Bar"
293
+ barColorPositive="#0a0"
294
+ barColorNegative="#a00"
295
+ formatTop={(v) => String(v)}
296
+ formatBottom={(v) => v.toFixed(2)}
297
+ formatX={(key) => `X:${key}`}
298
+ />,
299
+ )
300
+
301
+ const slider = screen.getByRole('slider')
302
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
303
+
304
+ expect(slider.getAttribute('aria-valuetext')).toBe('X:2026-08-01')
305
+ })
306
+ })
307
+
308
+ describe('DualPanel — only the top overlay is keyboard-focusable', () => {
309
+ test('renders exactly one role="slider" element — the bottom overlay stays pointer-only', () => {
310
+ render(
311
+ <DualPanel<Row>
312
+ data={rows}
313
+ chartId="dp-one-slider"
314
+ getX={(d) => d.date}
315
+ series={series}
316
+ getBar={(d) => d.bar}
317
+ barLabel="Bar"
318
+ barColorPositive="#0a0"
319
+ barColorNegative="#a00"
320
+ formatTop={(v) => String(v)}
321
+ formatBottom={(v) => v.toFixed(2)}
322
+ />,
323
+ )
324
+
325
+ // Two focusable tab stops for one logical x-axis is exactly what the pointer-only bottom
326
+ // overlay exists to prevent — this fails the moment a future edit adds onKeyDown there too.
327
+ expect(screen.getAllByRole('slider')).toHaveLength(1)
328
+ })
329
+ })
330
+
99
331
  describe('DualPanel — fixed bottomYDomain ships verbatim (un-niced)', () => {
100
332
  test('bottom axis ticks come from the pinned domain, not the computed max-abs', () => {
101
333
  // Data's max abs is 0.02 — an auto domain would never produce a "5.00" tick. Pinning
@@ -129,3 +361,161 @@ describe('DualPanel — formatBar overrides only the tooltip row', () => {
129
361
  expect(await screen.findByText('0.020 m/s (2026-08-01)')).toBeTruthy()
130
362
  })
131
363
  })
364
+
365
+ describe('DualPanel — cursorResolution threads through to sibling resolution', () => {
366
+ // `DualPanel` composes `ChartFrame` + `useChartCursor` directly rather than `CartesianChart`, so
367
+ // its forward runs through a different path than every other kind — verify it here rather than
368
+ // assuming the `CartesianChart`-composing pattern (`Bars.test.tsx`) ports verbatim.
369
+ //
370
+ // `DualPanel` forwards `ariaLabel` onto its own keyboard overlay (parity with every other kind),
371
+ // so the resolved bucket can be read straight off `aria-valuetext` — mirrors
372
+ // `Bars.test.tsx`'s cursorResolution assertions.
373
+ type DailyRow = { date: string; shown: number }
374
+ type FoldedRow = { date: string; v: number; bar: number }
375
+
376
+ const dailyRows: DailyRow[] = Array.from({ length: 14 }, (_, i) => ({
377
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
378
+ shown: i,
379
+ }))
380
+ const foldedRows: FoldedRow[] = [
381
+ { date: '2026-08-01', v: 0, bar: 5 },
382
+ { date: '2026-08-08', v: 1, bar: -5 },
383
+ ]
384
+ const dailySeries: ChartSeries<DailyRow>[] = [
385
+ { key: 'shown', label: 'Shown', color: '#111', mark: 'line', getValue: (d) => d.shown },
386
+ ]
387
+ const foldedSeries: ChartSeries<FoldedRow>[] = [
388
+ { key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
389
+ ]
390
+
391
+ function renderPair(cursorResolution?: 'leading') {
392
+ render(
393
+ <ChartCursorScope>
394
+ <CartesianChart<DailyRow>
395
+ data={dailyRows}
396
+ chartId="dp-cursor-daily"
397
+ getX={(d) => d.date}
398
+ series={dailySeries}
399
+ ariaLabel="Daily"
400
+ >
401
+ {() => null}
402
+ </CartesianChart>
403
+ <DualPanel<FoldedRow>
404
+ data={foldedRows}
405
+ chartId="dp-cursor-folded"
406
+ getX={(d) => d.date}
407
+ series={foldedSeries}
408
+ getBar={(d) => d.bar}
409
+ barLabel="Bar"
410
+ barColorPositive="#0a0"
411
+ barColorNegative="#a00"
412
+ formatTop={(v) => String(v)}
413
+ formatBottom={(v) => v.toFixed(2)}
414
+ ariaLabel="Folded"
415
+ {...(cursorResolution !== undefined && { cursorResolution })}
416
+ />
417
+ </ChartCursorScope>,
418
+ )
419
+ }
420
+
421
+ // Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
422
+ // via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
423
+ function driveDailyToAug05() {
424
+ const dailySlider = screen.getByRole('slider', { name: 'Daily' })
425
+ for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
426
+ }
427
+
428
+ test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
429
+ renderPair('leading')
430
+ driveDailyToAug05()
431
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
432
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
433
+ })
434
+
435
+ test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
436
+ renderPair()
437
+ driveDailyToAug05()
438
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
439
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
440
+ })
441
+ })
442
+
443
+ describe('DualPanel — onFollow (follower behaviour parity with CartesianChart)', () => {
444
+ type SourceRow = { date: string; shown: number }
445
+ const sourceRows: SourceRow[] = [{ date: '2026-08-01', shown: 10 }]
446
+ const sourceSeries: ChartSeries<SourceRow>[] = [
447
+ { key: 'shown', label: 'Shown', color: '#111', mark: 'line', getValue: (d) => d.shown },
448
+ ]
449
+
450
+ function renderPair(onFollow?: boolean, followerRows: Row[] = rows) {
451
+ render(
452
+ <ChartCursorScope>
453
+ <CartesianChart<SourceRow>
454
+ data={sourceRows}
455
+ chartId="dp-of-source"
456
+ getX={(d) => d.date}
457
+ series={sourceSeries}
458
+ ariaLabel="Source"
459
+ legend={false}
460
+ >
461
+ {() => null}
462
+ </CartesianChart>
463
+ <DualPanel<Row>
464
+ data={followerRows}
465
+ chartId="dp-of-follower"
466
+ getX={(d) => d.date}
467
+ series={series}
468
+ getBar={(d) => d.bar}
469
+ barLabel="Bar"
470
+ barColorPositive="#0a0"
471
+ barColorNegative="#a00"
472
+ formatTop={(v) => String(v)}
473
+ formatBottom={(v) => v.toFixed(2)}
474
+ ariaLabel="Follower"
475
+ legend={false}
476
+ {...(onFollow !== undefined && { onFollow })}
477
+ />
478
+ </ChartCursorScope>,
479
+ )
480
+ }
481
+
482
+ test('onFollow: true renders the DualPanel tooltip while a sibling owns the cursor', async () => {
483
+ renderPair(true)
484
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
485
+
486
+ expect(await screen.findByText('V')).toBeTruthy()
487
+ })
488
+
489
+ test('without onFollow (default false), DualPanel renders no tooltip as a follower', async () => {
490
+ renderPair()
491
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
492
+
493
+ // The SOURCE still renders its own tooltip — only the DualPanel follower stays silent.
494
+ expect(await screen.findByText('Shown')).toBeTruthy()
495
+ expect(screen.queryByText('V')).toBeNull()
496
+ expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
497
+ })
498
+
499
+ test('the follower tooltip is not aria-live; the source is', async () => {
500
+ renderPair(true)
501
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
502
+ await screen.findByText('V')
503
+
504
+ const tooltips = screen.getAllByRole('tooltip')
505
+ expect(tooltips).toHaveLength(2)
506
+ const sourceTooltip = tooltips.find((t) => within(t).queryByText('Shown') !== null)
507
+ const followerTooltip = tooltips.find((t) => within(t).queryByText('V') !== null)
508
+ expect(sourceTooltip?.getAttribute('aria-live')).toBe('polite')
509
+ expect(followerTooltip?.hasAttribute('aria-live')).toBe(false)
510
+ })
511
+
512
+ test('a follower whose domain never resolves the broadcast key renders no tooltip shell', async () => {
513
+ const foreignRows: Row[] = [{ date: '2027-01-15', v: 99, bar: 0.5 }]
514
+ renderPair(true, foreignRows)
515
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
516
+
517
+ expect(await screen.findByText('Shown')).toBeTruthy()
518
+ expect(screen.queryByText('V')).toBeNull()
519
+ expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
520
+ })
521
+ })
@@ -4,7 +4,7 @@ import { Group } from '@visx/group'
4
4
  import { scaleLinear, scalePoint } from '@visx/scale'
5
5
  import { Bar, LinePath } from '@visx/shape'
6
6
  import { Threshold } from '@visx/threshold'
7
- import { memo, useMemo } from 'react'
7
+ import { memo, useMemo, useRef } from 'react'
8
8
  import type { ReactNode } from 'react'
9
9
  import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
10
10
  import {
@@ -18,6 +18,7 @@ import { Crosshair, SeriesDot } from '../primitives/Crosshair'
18
18
  import { HoverOverlay } from '../primitives/HoverOverlay'
19
19
  import { ZoneRects } from '../primitives/ZoneRects'
20
20
  import type { ZoneSpec } from '../primitives/ZoneRects'
21
+ import type { CursorResolution } from '../cursor/resolve'
21
22
  import { useChartCursor } from '../hooks/useChartCursor'
22
23
  import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
23
24
  import { deriveTooltipRows, LINE_OVERLAY_STROKE_WIDTH } from '../series'
@@ -32,6 +33,15 @@ export type DualPanelProps<T> = {
32
33
  height?: number
33
34
  chartId: string
34
35
  getX: (d: T) => string
36
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM) — same default `CartesianChart` uses,
37
+ * so omitting it is byte-identical to today's rendering. */
38
+ formatX?: (key: string) => string
39
+ /**
40
+ * How a sibling chart's broadcast cursor key resolves against this chart's points. Default
41
+ * `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
42
+ * keyed by its Monday) — see `CursorResolution`.
43
+ */
44
+ cursorResolution?: CursorResolution
35
45
  /** 1+ line series in the top pane — the single source of truth for color, dash, legend, and
36
46
  * tooltip rows. E.g. acute (solid) / chronic (dashed). */
37
47
  series: ChartSeries<T>[]
@@ -67,6 +77,20 @@ export type DualPanelProps<T> = {
67
77
  bottomMaxAbsFloor?: number
68
78
  /** Tooltip badge — appears at the right of the tooltip header. */
69
79
  tooltipLabel?: (d: T) => { text: string; color: string } | null
80
+ /**
81
+ * Overrides the tooltip header's date text — the same seam `CartesianChart`'s
82
+ * `CartesianTooltipConfig.formatHeader` exposes, so the two don't diverge. Default:
83
+ * `TooltipHeader`'s own `fmtTooltipDate` behavior, unchanged. Receives the raw `getX` key
84
+ * alongside the hovered datum.
85
+ */
86
+ formatHeader?: (key: string, d: T) => string
87
+ /**
88
+ * Render this chart's tooltip when it is a cursor FOLLOWER, not only when it is the SOURCE —
89
+ * same seam and same anchored-positioning behaviour as `CartesianTooltipConfig.onFollow`. Default
90
+ * false (today's behaviour). Only the SOURCE's tooltip is `aria-live`; a follower's is visual
91
+ * only.
92
+ */
93
+ onFollow?: boolean
70
94
  /** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
71
95
  * Default `{ placement: 'bottom' }`. */
72
96
  legend?: ChartLegendConfig | false
@@ -138,6 +162,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
138
162
  data,
139
163
  chartId,
140
164
  getX,
165
+ formatX = fmtAxisDate,
166
+ cursorResolution,
167
+ ariaLabel,
141
168
  series,
142
169
  topYDomain = 'auto',
143
170
  fillBetween,
@@ -154,6 +181,8 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
154
181
  bottomYDomain = 'auto',
155
182
  bottomMaxAbsFloor,
156
183
  tooltipLabel,
184
+ formatHeader,
185
+ onFollow,
157
186
  plot,
158
187
  } = props
159
188
 
@@ -237,7 +266,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
237
266
 
238
267
  // Full (untinned) x labels are enough to measure the bottom/right gutters — thinning only drops
239
268
  // labels, never widens the max, so measuring against the full set is a safe over-estimate.
240
- const xLabelsAll = useMemo(() => data.map((d) => fmtAxisDate(getX(d))), [data, getX])
269
+ // Uses the SAME `formatX` the real axis paints with (`AxisBottomDate` below), or the measured
270
+ // gutter would be computed from labels the axis never paints.
271
+ const xLabelsAll = useMemo(() => data.map((d) => formatX(getX(d))), [data, getX, formatX])
241
272
 
242
273
  const margin = useMemo(
243
274
  () => autoMargin({ left: [...topLabels, ...bottomLabels], bottom: xLabelsAll }),
@@ -285,6 +316,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
285
316
  getKey: getX,
286
317
  xScale,
287
318
  marginLeft: margin.left,
319
+ ...(cursorResolution !== undefined && { resolution: cursorResolution }),
288
320
  })
289
321
 
290
322
  const tickValues = useMemo(() => smartTicks(data.map(getX), xMax), [data, xMax, getX])
@@ -382,6 +414,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
382
414
  if (m === null) continue
383
415
  const cx = xScale(getX(p.__d)) ?? 0
384
416
  const cy = topYScale(p.__y)
417
+ const ring = m.ring ?? true
385
418
  markers.push(
386
419
  <circle
387
420
  key={`mk-${s.key}-${getX(p.__d)}`}
@@ -389,8 +422,8 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
389
422
  cy={cy}
390
423
  r={m.r ?? VX.dotR}
391
424
  fill={m.color ?? s.color}
392
- stroke={VX.dotStroke}
393
- strokeWidth={2}
425
+ fillOpacity={m.fillOpacity ?? 1}
426
+ {...(ring && { stroke: VX.dotStroke, strokeWidth: 2 })}
394
427
  />,
395
428
  )
396
429
  }
@@ -399,13 +432,27 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
399
432
  [visibleSeries, lineValid, xScale, topYScale, getX],
400
433
  )
401
434
 
435
+ const svgRef = useRef<SVGSVGElement>(null)
402
436
  const point = cursor.point
403
437
  const sx = point !== null ? (xScale(getX(point)) ?? 0) : 0
404
438
  const syncedBar = point !== null ? getBar(point) : null
405
439
 
440
+ // Same seam as `CartesianChart`: this chart renders its tooltip as the cursor SOURCE (always) or,
441
+ // opted in via `onFollow`, as a FOLLOWER. A follower has no pointer under it, so it is always
442
+ // positioned via the anchored (crosshair) path — `cursor.anchor` stays null on a chart nobody is
443
+ // hovering, so following it here would render nothing.
444
+ const isFollowerRender = !cursor.isSource && onFollow === true
445
+ const showTooltip = point !== null && (cursor.isSource || isFollowerRender)
446
+ const anchorX = isFollowerRender && point !== null ? sx : null
447
+ const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
448
+ const tooltipAnchor =
449
+ svgRect === undefined || anchorX === null
450
+ ? cursor.anchor
451
+ : { x: svgRect.left + margin.left + anchorX, y: svgRect.top + margin.top }
452
+
406
453
  return (
407
454
  <>
408
- <svg width={plot.width} height={plot.height}>
455
+ <svg ref={svgRef} width={plot.width} height={plot.height}>
409
456
  {/* Top pane: line series + fill-between + zones + ref lines. */}
410
457
  <Group left={margin.left} top={margin.top}>
411
458
  <GridRows scale={topYScale} width={xMax} stroke={VX.grid} numTicks={4} />
@@ -484,6 +531,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
484
531
  {/* One HoverOverlay per pane, both driving the SAME shared cursor — snap-to-nearest is
485
532
  x-only, so either overlay yields the same point. The top overlay extends over the gutter
486
533
  (height = topH + PANE_GAP) so there's no dead zone between the panes. */}
534
+ {/* The focusable overlay is the ONE keyboard control for both panes, so it carries the
535
+ slider's label and value — parity with `CartesianChart`. Without them the frame's
536
+ `aria-label` announces the chart and the slider underneath it is an unnamed control
537
+ reporting no position: technically focusable, useless to read. The bottom overlay
538
+ stays pointer-only and therefore deliberately carries neither. */}
487
539
  <HoverOverlay
488
540
  width={xMax}
489
541
  height={topH + PANE_GAP}
@@ -491,6 +543,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
491
543
  onLeave={cursor.onPointerLeave}
492
544
  onKeyDown={cursor.onKeyDown}
493
545
  onBlur={cursor.onBlur}
546
+ valueMax={Math.max(data.length - 1, 0)}
547
+ {...(point !== null && {
548
+ valueNow: data.indexOf(point),
549
+ valueText: formatX(getX(point)),
550
+ })}
551
+ {...(ariaLabel !== undefined && { ariaLabel })}
494
552
  />
495
553
  </Group>
496
554
 
@@ -531,7 +589,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
531
589
  )}
532
590
 
533
591
  <AxisLeftNumeric scale={bottomYScale} numTicks={3} tickFormat={formatBottom} />
534
- <AxisBottomDate top={bottomH} scale={xScale} tickValues={tickValues} />
592
+ <AxisBottomDate
593
+ top={bottomH}
594
+ scale={xScale}
595
+ tickValues={tickValues}
596
+ tickFormat={formatX}
597
+ />
535
598
  {/* Pointer only — deliberately NO `onKeyDown`, which is what makes an overlay focusable.
536
599
  Both panes scrub the same single x cursor, so a second tab stop would be a second
537
600
  control for one logical axis: a keyboard user would tab twice through one chart and
@@ -552,10 +615,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
552
615
  )}
553
616
  </svg>
554
617
 
555
- {cursor.isSource && point !== null && (
556
- <ChartTooltipFloat anchor={cursor.anchor}>
618
+ {showTooltip && (
619
+ <ChartTooltipFloat anchor={tooltipAnchor} ariaLive={cursor.isSource}>
557
620
  <TooltipHeader
558
621
  date={getX(point)}
622
+ {...(formatHeader !== undefined && {
623
+ format: (key: string) => formatHeader(key, point),
624
+ })}
559
625
  {...(() => {
560
626
  const lbl = tooltipLabel?.(point) ?? null
561
627
  return lbl !== null ? { label: lbl.text, labelColor: lbl.color } : {}