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
@@ -5,6 +5,7 @@ import { scaleLinear, scalePoint } from '@visx/scale'
5
5
  import { Bar, LinePath } from '@visx/shape'
6
6
  import { Threshold } from '@visx/threshold'
7
7
  import { memo, useMemo } from 'react'
8
+ import type { ReactNode } from 'react'
8
9
  import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
9
10
  import {
10
11
  ChartTooltipFloat,
@@ -17,6 +18,7 @@ import { Crosshair, SeriesDot } from '../primitives/Crosshair'
17
18
  import { HoverOverlay } from '../primitives/HoverOverlay'
18
19
  import { ZoneRects } from '../primitives/ZoneRects'
19
20
  import type { ZoneSpec } from '../primitives/ZoneRects'
21
+ import type { CursorResolution } from '../cursor/resolve'
20
22
  import { useChartCursor } from '../hooks/useChartCursor'
21
23
  import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
22
24
  import { deriveTooltipRows, LINE_OVERLAY_STROKE_WIDTH } from '../series'
@@ -31,6 +33,15 @@ export type DualPanelProps<T> = {
31
33
  height?: number
32
34
  chartId: string
33
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
34
45
  /** 1+ line series in the top pane — the single source of truth for color, dash, legend, and
35
46
  * tooltip rows. E.g. acute (solid) / chronic (dashed). */
36
47
  series: ChartSeries<T>[]
@@ -50,9 +61,29 @@ export type DualPanelProps<T> = {
50
61
  /** Top pane share of the inner plot height. Default 0.62. */
51
62
  topFraction?: number
52
63
  formatTop: (v: number) => string
64
+ /** Drives the bottom-pane TICKS. Also the tooltip-row fallback when `formatBar` is omitted. */
53
65
  formatBottom: (v: number) => string
66
+ /** Per-row tooltip formatter for the bottom-pane bar — overrides `formatBottom` for the tooltip
67
+ * row only (ticks keep reading `formatBottom`). Receives the hovered datum alongside the value,
68
+ * so a row can add a unit/decimal `formatBottom` can't (e.g. a velocity reading). */
69
+ formatBar?: (v: number, d: T) => string
70
+ /**
71
+ * Bottom-pane y-domain. Default `'auto'`: a symmetric `[-maxAbs, maxAbs]` computed from the
72
+ * data, `nice`d. A fixed tuple pins the domain instead (and is used verbatim, un-`nice`d).
73
+ */
74
+ bottomYDomain?: [number, number] | 'auto'
75
+ /** When `bottomYDomain` is `'auto'`: floors the symmetric max-abs so a plateau doesn't amplify
76
+ * to fill the whole pane (mirrors `AxisConfig.autoMaxFloor`). Ignored with a fixed tuple. */
77
+ bottomMaxAbsFloor?: number
54
78
  /** Tooltip badge — appears at the right of the tooltip header. */
55
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
56
87
  /** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
57
88
  * Default `{ placement: 'bottom' }`. */
58
89
  legend?: ChartLegendConfig | false
@@ -124,6 +155,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
124
155
  data,
125
156
  chartId,
126
157
  getX,
158
+ formatX = fmtAxisDate,
159
+ cursorResolution,
160
+ ariaLabel,
127
161
  series,
128
162
  topYDomain = 'auto',
129
163
  fillBetween,
@@ -136,7 +170,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
136
170
  topFraction = 0.62,
137
171
  formatTop,
138
172
  formatBottom,
173
+ formatBar,
174
+ bottomYDomain = 'auto',
175
+ bottomMaxAbsFloor,
139
176
  tooltipLabel,
177
+ formatHeader,
140
178
  plot,
141
179
  } = props
142
180
 
@@ -184,8 +222,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
184
222
  [topDomain, useNiceTop, formatTop],
185
223
  )
186
224
 
187
- // Bottom pane: symmetric signed domain around zero.
225
+ // Bottom pane: symmetric signed domain around zero — unless `bottomYDomain` pins a fixed tuple,
226
+ // in which case that domain ships verbatim (un-`nice`d, matching `topYDomain`'s fixed-tuple path).
227
+ const useNiceBottom = bottomYDomain === 'auto'
188
228
  const maxAbs = useMemo(() => {
229
+ if (bottomYDomain !== 'auto') return 0
189
230
  let m = 0
190
231
  for (const d of data) {
191
232
  const v = getBar(d)
@@ -193,23 +234,33 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
193
234
  const a = Math.abs(v)
194
235
  if (a > m) m = a
195
236
  }
196
- return m === 0 ? 1 : m
197
- }, [data, getBar])
237
+ const floored = bottomMaxAbsFloor !== undefined ? Math.max(m, bottomMaxAbsFloor) : m
238
+ return floored === 0 ? 1 : floored
239
+ }, [data, getBar, bottomYDomain, bottomMaxAbsFloor])
198
240
 
241
+ const bottomDomain = useMemo<[number, number]>(
242
+ () => (bottomYDomain === 'auto' ? [-maxAbs, maxAbs] : bottomYDomain),
243
+ [bottomYDomain, maxAbs],
244
+ )
245
+
246
+ // `bottomLabels`/`probeAxisLabels` measure whatever domain actually ships (fixed tuple or
247
+ // computed maxAbs), so the margin is never computed from ticks the axis never paints.
199
248
  const bottomLabels = useMemo(
200
249
  () =>
201
250
  probeAxisLabels({
202
- domain: [-maxAbs, maxAbs],
251
+ domain: bottomDomain,
203
252
  ticks: 3,
204
253
  format: formatBottom,
205
- nice: true,
254
+ nice: useNiceBottom,
206
255
  }).labels,
207
- [maxAbs, formatBottom],
256
+ [bottomDomain, formatBottom, useNiceBottom],
208
257
  )
209
258
 
210
259
  // Full (untinned) x labels are enough to measure the bottom/right gutters — thinning only drops
211
260
  // labels, never widens the max, so measuring against the full set is a safe over-estimate.
212
- const xLabelsAll = useMemo(() => data.map((d) => fmtAxisDate(getX(d))), [data, getX])
261
+ // Uses the SAME `formatX` the real axis paints with (`AxisBottomDate` below), or the measured
262
+ // gutter would be computed from labels the axis never paints.
263
+ const xLabelsAll = useMemo(() => data.map((d) => formatX(getX(d))), [data, getX, formatX])
213
264
 
214
265
  const margin = useMemo(
215
266
  () => autoMargin({ left: [...topLabels, ...bottomLabels], bottom: xLabelsAll }),
@@ -242,8 +293,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
242
293
  )
243
294
 
244
295
  const bottomYScale = useMemo(
245
- () => scaleLinear<number>({ domain: [-maxAbs, maxAbs], range: [bottomH, 0], nice: true }),
246
- [maxAbs, bottomH],
296
+ () =>
297
+ scaleLinear<number>({
298
+ domain: bottomDomain,
299
+ range: [bottomH, 0],
300
+ ...(useNiceBottom && { nice: true }),
301
+ }),
302
+ [bottomDomain, bottomH, useNiceBottom],
247
303
  )
248
304
 
249
305
  const cursor = useChartCursor<T>({
@@ -252,6 +308,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
252
308
  getKey: getX,
253
309
  xScale,
254
310
  marginLeft: margin.left,
311
+ ...(cursorResolution !== undefined && { resolution: cursorResolution }),
255
312
  })
256
313
 
257
314
  const tickValues = useMemo(() => smartTicks(data.map(getX), xMax), [data, xMax, getX])
@@ -334,6 +391,39 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
334
391
  return out
335
392
  }, [data, visibleSeries])
336
393
 
394
+ // Per-point markers (PR star / status dots), memoized for the same reason as `histogramBars` /
395
+ // `lineValid` — the shared cursor re-renders every mounted chart on every pointer frame, and this
396
+ // walks every visible series' valid points calling the consumer's `getMarker` each time otherwise.
397
+ const markerNodes = useMemo<ReactNode[]>(
398
+ () =>
399
+ visibleSeries.flatMap((s) => {
400
+ const getMarker = s.getMarker
401
+ if (!getMarker) return [] as ReactNode[]
402
+ const pts = lineValid.get(s.key) ?? []
403
+ const markers: ReactNode[] = []
404
+ for (const p of pts) {
405
+ const m = getMarker(p.__d)
406
+ if (m === null) continue
407
+ const cx = xScale(getX(p.__d)) ?? 0
408
+ const cy = topYScale(p.__y)
409
+ const ring = m.ring ?? true
410
+ markers.push(
411
+ <circle
412
+ key={`mk-${s.key}-${getX(p.__d)}`}
413
+ cx={cx}
414
+ cy={cy}
415
+ r={m.r ?? VX.dotR}
416
+ fill={m.color ?? s.color}
417
+ fillOpacity={m.fillOpacity ?? 1}
418
+ {...(ring && { stroke: VX.dotStroke, strokeWidth: 2 })}
419
+ />,
420
+ )
421
+ }
422
+ return markers
423
+ }),
424
+ [visibleSeries, lineValid, xScale, topYScale, getX],
425
+ )
426
+
337
427
  const point = cursor.point
338
428
  const sx = point !== null ? (xScale(getX(point)) ?? 0) : 0
339
429
  const syncedBar = point !== null ? getBar(point) : null
@@ -386,11 +476,16 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
386
476
  stroke={s.color}
387
477
  strokeWidth={s.strokeWidth ?? VX.lineWidth}
388
478
  strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
479
+ strokeOpacity={s.strokeOpacity ?? 1}
389
480
  curve={curveMonotoneX}
390
481
  />
391
482
  )
392
483
  })}
393
484
 
485
+ {/* Per-point markers (PR star / status dots) — plain-circle parity with MultiLine's
486
+ `getMarker` handling; DualPanel doesn't need the star-shape variant. */}
487
+ {markerNodes}
488
+
394
489
  {/* Dots only — the crosshair LINE is the single continuous span drawn below. */}
395
490
  {point !== null && (
396
491
  <>
@@ -414,6 +509,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
414
509
  {/* One HoverOverlay per pane, both driving the SAME shared cursor — snap-to-nearest is
415
510
  x-only, so either overlay yields the same point. The top overlay extends over the gutter
416
511
  (height = topH + PANE_GAP) so there's no dead zone between the panes. */}
512
+ {/* The focusable overlay is the ONE keyboard control for both panes, so it carries the
513
+ slider's label and value — parity with `CartesianChart`. Without them the frame's
514
+ `aria-label` announces the chart and the slider underneath it is an unnamed control
515
+ reporting no position: technically focusable, useless to read. The bottom overlay
516
+ stays pointer-only and therefore deliberately carries neither. */}
417
517
  <HoverOverlay
418
518
  width={xMax}
419
519
  height={topH + PANE_GAP}
@@ -421,6 +521,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
421
521
  onLeave={cursor.onPointerLeave}
422
522
  onKeyDown={cursor.onKeyDown}
423
523
  onBlur={cursor.onBlur}
524
+ valueMax={Math.max(data.length - 1, 0)}
525
+ {...(point !== null && {
526
+ valueNow: data.indexOf(point),
527
+ valueText: formatX(getX(point)),
528
+ })}
529
+ {...(ariaLabel !== undefined && { ariaLabel })}
424
530
  />
425
531
  </Group>
426
532
 
@@ -461,7 +567,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
461
567
  )}
462
568
 
463
569
  <AxisLeftNumeric scale={bottomYScale} numTicks={3} tickFormat={formatBottom} />
464
- <AxisBottomDate top={bottomH} scale={xScale} tickValues={tickValues} />
570
+ <AxisBottomDate
571
+ top={bottomH}
572
+ scale={xScale}
573
+ tickValues={tickValues}
574
+ tickFormat={formatX}
575
+ />
465
576
  {/* Pointer only — deliberately NO `onKeyDown`, which is what makes an overlay focusable.
466
577
  Both panes scrub the same single x cursor, so a second tab stop would be a second
467
578
  control for one logical axis: a keyboard user would tab twice through one chart and
@@ -486,6 +597,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
486
597
  <ChartTooltipFloat anchor={cursor.anchor}>
487
598
  <TooltipHeader
488
599
  date={getX(point)}
600
+ {...(formatHeader !== undefined && {
601
+ format: (key: string) => formatHeader(key, point),
602
+ })}
489
603
  {...(() => {
490
604
  const lbl = tooltipLabel?.(point) ?? null
491
605
  return lbl !== null ? { label: lbl.text, labelColor: lbl.color } : {}
@@ -507,11 +621,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
507
621
  (() => {
508
622
  const v = getBar(point)
509
623
  if (v === null || v === undefined || Number.isNaN(v)) return null
624
+ // `formatBar` overrides the tooltip row only — `formatBottom` keeps owning ticks.
625
+ const value = formatBar !== undefined ? formatBar(v, point) : formatBottom(v)
510
626
  return (
511
627
  <TooltipRow
512
628
  color={v >= 0 ? barColorPositive : barColorNegative}
513
629
  label={barLabel}
514
- value={formatBottom(v)}
630
+ value={value}
515
631
  shape="bar"
516
632
  />
517
633
  )
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `MultiLine` — the `strokeOpacity` wiring: `dimOpacity(s) * (s.strokeOpacity ?? 1)`. SSR harness
3
+ * (no hover 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 { MultiLine } from './MultiLine'
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('MultiLine — 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: '#123456',
27
+ mark: 'line',
28
+ getValue: (d) => d.v,
29
+ strokeOpacity: 0.35,
30
+ },
31
+ ]
32
+
33
+ const html = renderToStaticMarkup(
34
+ <MultiLine<Row> data={rows} chartId="ml-opacity" getX={(d) => d.date} series={series} />,
35
+ )
36
+
37
+ expect(html).toContain('stroke="#123456"')
38
+ expect(html).toContain('stroke-opacity="0.35"')
39
+ })
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
+ })
@@ -1,7 +1,8 @@
1
1
  import { curveMonotoneX } from '@visx/curve'
2
2
  import { LinePath } from '@visx/shape'
3
- import { memo, useMemo } from 'react'
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 })}
@@ -163,35 +176,27 @@ function MultiLineMarks<T>({
163
176
  }, [visible, data])
164
177
 
165
178
  // A series stays at full opacity when nothing is highlighted, when it IS the highlighted series,
166
- // or when it is a companion of the highlighted series (its `parent` matches).
167
- const dimOpacity = (s: ChartSeries<T>): number =>
168
- highlighted === null || s.key === highlighted || s.parent === highlighted ? 1 : 0.25
169
-
170
- const scaleFor = (s: ChartSeries<T>) =>
171
- s.axis === 'right' && y2Scale !== null ? y2Scale : yScale
179
+ // or when it is a companion of the highlighted series (its `parent` matches). `useCallback`
180
+ // (rather than a plain function) gives both a stable identity across renders where `highlighted`/
181
+ // `y2Scale` haven't changed, so `markerNodes` below can depend on THEM rather than the raw
182
+ // scale/highlighted values a closure would otherwise hide from the deps array.
183
+ const dimOpacity = useCallback(
184
+ (s: ChartSeries<T>): number =>
185
+ highlighted === null || s.key === highlighted || s.parent === highlighted ? 1 : 0.25,
186
+ [highlighted],
187
+ )
172
188
 
173
- return (
174
- <>
175
- {visible.map((s) => {
176
- const valid = pointsBySeries.get(s.key) ?? []
177
- if (valid.length === 0) return null
178
- const scale = scaleFor(s)
179
- return (
180
- <LinePath<LinePt<T>>
181
- key={`line-${s.key}`}
182
- data={valid}
183
- x={(p) => xScale(getX(p.__d)) ?? 0}
184
- y={(p) => scale(p.__y)}
185
- stroke={s.color}
186
- strokeWidth={s.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
187
- strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
188
- strokeOpacity={dimOpacity(s)}
189
- curve={curveMonotoneX}
190
- />
191
- )
192
- })}
189
+ const scaleFor = useCallback(
190
+ (s: ChartSeries<T>) => (s.axis === 'right' && y2Scale !== null ? y2Scale : yScale),
191
+ [y2Scale, yScale],
192
+ )
193
193
 
194
- {visible.flatMap((s) => {
194
+ // Memoized for the same reason as `pointsBySeries` above — the shared cursor re-renders every
195
+ // mounted chart on every pointer frame, and this walks every visible series' points calling the
196
+ // consumer's `getMarker` each time otherwise.
197
+ const markerNodes = useMemo<ReactNode[]>(
198
+ () =>
199
+ visible.flatMap((s) => {
195
200
  const getMarker = s.getMarker
196
201
  if (!getMarker) return [] as ReactNode[]
197
202
  const scale = scaleFor(s)
@@ -204,16 +209,16 @@ function MultiLineMarks<T>({
204
209
  const cy = scale(p.__y)
205
210
  const color = m.color ?? s.color
206
211
  const r = m.r ?? (markerShape === 'star' ? STAR_R : VX.dotR)
212
+ const ring = m.ring ?? true
213
+ const fillOpacity = (m.fillOpacity ?? 1) * op
207
214
  markers.push(
208
215
  markerShape === 'star' ? (
209
216
  <path
210
217
  key={`mk-${s.key}-${getX(p.__d)}`}
211
218
  d={starPath(cx, cy, r)}
212
219
  fill={color}
213
- stroke={VX.dotStroke}
214
- strokeWidth={1.5}
215
- fillOpacity={op}
216
- strokeOpacity={op}
220
+ fillOpacity={fillOpacity}
221
+ {...(ring && { stroke: VX.dotStroke, strokeWidth: 1.5, strokeOpacity: op })}
217
222
  />
218
223
  ) : (
219
224
  <circle
@@ -222,16 +227,39 @@ function MultiLineMarks<T>({
222
227
  cy={cy}
223
228
  r={r}
224
229
  fill={color}
225
- stroke={VX.dotStroke}
226
- strokeWidth={2}
227
- fillOpacity={op}
228
- strokeOpacity={op}
230
+ fillOpacity={fillOpacity}
231
+ {...(ring && { stroke: VX.dotStroke, strokeWidth: 2, strokeOpacity: op })}
229
232
  />
230
233
  ),
231
234
  )
232
235
  }
233
236
  return markers
237
+ }),
238
+ [visible, pointsBySeries, xScale, getX, scaleFor, dimOpacity, markerShape],
239
+ )
240
+
241
+ return (
242
+ <>
243
+ {visible.map((s) => {
244
+ const valid = pointsBySeries.get(s.key) ?? []
245
+ if (valid.length === 0) return null
246
+ const scale = scaleFor(s)
247
+ return (
248
+ <LinePath<LinePt<T>>
249
+ key={`line-${s.key}`}
250
+ data={valid}
251
+ x={(p) => xScale(getX(p.__d)) ?? 0}
252
+ y={(p) => scale(p.__y)}
253
+ stroke={s.color}
254
+ strokeWidth={s.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
255
+ strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
256
+ strokeOpacity={dimOpacity(s) * (s.strokeOpacity ?? 1)}
257
+ curve={curveMonotoneX}
258
+ />
259
+ )
234
260
  })}
261
+
262
+ {markerNodes}
235
263
  </>
236
264
  )
237
265
  }