basalt-ui 1.15.0 → 1.16.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 (55) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/agent/rules/basalt-charts.md +28 -9
  3. package/agent/templates/CLAUDE-block.md.tpl +9 -6
  4. package/dist/charts/index.d.ts +1 -1
  5. package/dist/charts/index.d.ts.map +1 -1
  6. package/dist/charts/kinds/Bars.d.ts +21 -8
  7. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  8. package/dist/charts/kinds/Bars.js +6 -3
  9. package/dist/charts/kinds/Bars.test.d.ts +2 -0
  10. package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
  11. package/dist/charts/kinds/DualPanel.d.ts +13 -0
  12. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  13. package/dist/charts/kinds/DualPanel.js +55 -8
  14. package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
  15. package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
  16. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  17. package/dist/charts/kinds/MultiLine.js +35 -25
  18. package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
  19. package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
  20. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  21. package/dist/charts/kinds/ZonedLine.js +1 -0
  22. package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
  23. package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
  24. package/dist/charts/primitives/CartesianChart.d.ts +22 -4
  25. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  26. package/dist/charts/primitives/CartesianChart.js +18 -8
  27. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -1
  28. package/dist/charts/primitives/ChartLegend.d.ts +3 -0
  29. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  30. package/dist/charts/primitives/ChartLegend.js +4 -0
  31. package/dist/charts/primitives/XZoneRects.d.ts +22 -8
  32. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -1
  33. package/dist/charts/primitives/XZoneRects.js +39 -2
  34. package/dist/charts/series.d.ts +12 -2
  35. package/dist/charts/series.d.ts.map +1 -1
  36. package/dist/charts/series.js +2 -1
  37. package/llms.txt +2 -2
  38. package/package.json +1 -1
  39. package/src/charts/index.ts +1 -0
  40. package/src/charts/kinds/Bars.test.tsx +98 -0
  41. package/src/charts/kinds/Bars.tsx +28 -12
  42. package/src/charts/kinds/DualPanel.test.tsx +131 -0
  43. package/src/charts/kinds/DualPanel.tsx +81 -9
  44. package/src/charts/kinds/MultiLine.test.tsx +37 -0
  45. package/src/charts/kinds/MultiLine.tsx +45 -28
  46. package/src/charts/kinds/ZonedLine.test.tsx +37 -0
  47. package/src/charts/kinds/ZonedLine.tsx +1 -0
  48. package/src/charts/primitives/{CartesianChart.test.ts → CartesianChart.test.tsx} +62 -1
  49. package/src/charts/primitives/CartesianChart.tsx +44 -12
  50. package/src/charts/primitives/ChartLegend.test.tsx +30 -0
  51. package/src/charts/primitives/ChartLegend.tsx +7 -0
  52. package/src/charts/primitives/XZoneRects.test.tsx +60 -0
  53. package/src/charts/primitives/XZoneRects.tsx +75 -10
  54. package/src/charts/series.test.ts +51 -0
  55. package/src/charts/series.ts +15 -4
@@ -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,
@@ -50,7 +51,20 @@ export type DualPanelProps<T> = {
50
51
  /** Top pane share of the inner plot height. Default 0.62. */
51
52
  topFraction?: number
52
53
  formatTop: (v: number) => string
54
+ /** Drives the bottom-pane TICKS. Also the tooltip-row fallback when `formatBar` is omitted. */
53
55
  formatBottom: (v: number) => string
56
+ /** Per-row tooltip formatter for the bottom-pane bar — overrides `formatBottom` for the tooltip
57
+ * row only (ticks keep reading `formatBottom`). Receives the hovered datum alongside the value,
58
+ * so a row can add a unit/decimal `formatBottom` can't (e.g. a velocity reading). */
59
+ formatBar?: (v: number, d: T) => string
60
+ /**
61
+ * Bottom-pane y-domain. Default `'auto'`: a symmetric `[-maxAbs, maxAbs]` computed from the
62
+ * data, `nice`d. A fixed tuple pins the domain instead (and is used verbatim, un-`nice`d).
63
+ */
64
+ bottomYDomain?: [number, number] | 'auto'
65
+ /** When `bottomYDomain` is `'auto'`: floors the symmetric max-abs so a plateau doesn't amplify
66
+ * to fill the whole pane (mirrors `AxisConfig.autoMaxFloor`). Ignored with a fixed tuple. */
67
+ bottomMaxAbsFloor?: number
54
68
  /** Tooltip badge — appears at the right of the tooltip header. */
55
69
  tooltipLabel?: (d: T) => { text: string; color: string } | null
56
70
  /** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
@@ -136,6 +150,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
136
150
  topFraction = 0.62,
137
151
  formatTop,
138
152
  formatBottom,
153
+ formatBar,
154
+ bottomYDomain = 'auto',
155
+ bottomMaxAbsFloor,
139
156
  tooltipLabel,
140
157
  plot,
141
158
  } = props
@@ -184,8 +201,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
184
201
  [topDomain, useNiceTop, formatTop],
185
202
  )
186
203
 
187
- // Bottom pane: symmetric signed domain around zero.
204
+ // Bottom pane: symmetric signed domain around zero — unless `bottomYDomain` pins a fixed tuple,
205
+ // in which case that domain ships verbatim (un-`nice`d, matching `topYDomain`'s fixed-tuple path).
206
+ const useNiceBottom = bottomYDomain === 'auto'
188
207
  const maxAbs = useMemo(() => {
208
+ if (bottomYDomain !== 'auto') return 0
189
209
  let m = 0
190
210
  for (const d of data) {
191
211
  const v = getBar(d)
@@ -193,18 +213,26 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
193
213
  const a = Math.abs(v)
194
214
  if (a > m) m = a
195
215
  }
196
- return m === 0 ? 1 : m
197
- }, [data, getBar])
216
+ const floored = bottomMaxAbsFloor !== undefined ? Math.max(m, bottomMaxAbsFloor) : m
217
+ return floored === 0 ? 1 : floored
218
+ }, [data, getBar, bottomYDomain, bottomMaxAbsFloor])
198
219
 
220
+ const bottomDomain = useMemo<[number, number]>(
221
+ () => (bottomYDomain === 'auto' ? [-maxAbs, maxAbs] : bottomYDomain),
222
+ [bottomYDomain, maxAbs],
223
+ )
224
+
225
+ // `bottomLabels`/`probeAxisLabels` measure whatever domain actually ships (fixed tuple or
226
+ // computed maxAbs), so the margin is never computed from ticks the axis never paints.
199
227
  const bottomLabels = useMemo(
200
228
  () =>
201
229
  probeAxisLabels({
202
- domain: [-maxAbs, maxAbs],
230
+ domain: bottomDomain,
203
231
  ticks: 3,
204
232
  format: formatBottom,
205
- nice: true,
233
+ nice: useNiceBottom,
206
234
  }).labels,
207
- [maxAbs, formatBottom],
235
+ [bottomDomain, formatBottom, useNiceBottom],
208
236
  )
209
237
 
210
238
  // Full (untinned) x labels are enough to measure the bottom/right gutters — thinning only drops
@@ -242,8 +270,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
242
270
  )
243
271
 
244
272
  const bottomYScale = useMemo(
245
- () => scaleLinear<number>({ domain: [-maxAbs, maxAbs], range: [bottomH, 0], nice: true }),
246
- [maxAbs, bottomH],
273
+ () =>
274
+ scaleLinear<number>({
275
+ domain: bottomDomain,
276
+ range: [bottomH, 0],
277
+ ...(useNiceBottom && { nice: true }),
278
+ }),
279
+ [bottomDomain, bottomH, useNiceBottom],
247
280
  )
248
281
 
249
282
  const cursor = useChartCursor<T>({
@@ -334,6 +367,38 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
334
367
  return out
335
368
  }, [data, visibleSeries])
336
369
 
370
+ // Per-point markers (PR star / status dots), memoized for the same reason as `histogramBars` /
371
+ // `lineValid` — the shared cursor re-renders every mounted chart on every pointer frame, and this
372
+ // walks every visible series' valid points calling the consumer's `getMarker` each time otherwise.
373
+ const markerNodes = useMemo<ReactNode[]>(
374
+ () =>
375
+ visibleSeries.flatMap((s) => {
376
+ const getMarker = s.getMarker
377
+ if (!getMarker) return [] as ReactNode[]
378
+ const pts = lineValid.get(s.key) ?? []
379
+ const markers: ReactNode[] = []
380
+ for (const p of pts) {
381
+ const m = getMarker(p.__d)
382
+ if (m === null) continue
383
+ const cx = xScale(getX(p.__d)) ?? 0
384
+ const cy = topYScale(p.__y)
385
+ markers.push(
386
+ <circle
387
+ key={`mk-${s.key}-${getX(p.__d)}`}
388
+ cx={cx}
389
+ cy={cy}
390
+ r={m.r ?? VX.dotR}
391
+ fill={m.color ?? s.color}
392
+ stroke={VX.dotStroke}
393
+ strokeWidth={2}
394
+ />,
395
+ )
396
+ }
397
+ return markers
398
+ }),
399
+ [visibleSeries, lineValid, xScale, topYScale, getX],
400
+ )
401
+
337
402
  const point = cursor.point
338
403
  const sx = point !== null ? (xScale(getX(point)) ?? 0) : 0
339
404
  const syncedBar = point !== null ? getBar(point) : null
@@ -386,11 +451,16 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
386
451
  stroke={s.color}
387
452
  strokeWidth={s.strokeWidth ?? VX.lineWidth}
388
453
  strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
454
+ strokeOpacity={s.strokeOpacity ?? 1}
389
455
  curve={curveMonotoneX}
390
456
  />
391
457
  )
392
458
  })}
393
459
 
460
+ {/* Per-point markers (PR star / status dots) — plain-circle parity with MultiLine's
461
+ `getMarker` handling; DualPanel doesn't need the star-shape variant. */}
462
+ {markerNodes}
463
+
394
464
  {/* Dots only — the crosshair LINE is the single continuous span drawn below. */}
395
465
  {point !== null && (
396
466
  <>
@@ -507,11 +577,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
507
577
  (() => {
508
578
  const v = getBar(point)
509
579
  if (v === null || v === undefined || Number.isNaN(v)) return null
580
+ // `formatBar` overrides the tooltip row only — `formatBottom` keeps owning ticks.
581
+ const value = formatBar !== undefined ? formatBar(v, point) : formatBottom(v)
510
582
  return (
511
583
  <TooltipRow
512
584
  color={v >= 0 ? barColorPositive : barColorNegative}
513
585
  label={barLabel}
514
- value={formatBottom(v)}
586
+ value={value}
515
587
  shape="bar"
516
588
  />
517
589
  )
@@ -0,0 +1,37 @@
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 { describe, expect, test } from 'bun:test'
6
+ import { renderToStaticMarkup } from 'react-dom/server'
7
+ import { MultiLine } from './MultiLine'
8
+ import type { ChartSeries } from '../series'
9
+
10
+ type Row = { date: string; v: number }
11
+
12
+ const rows: Row[] = [
13
+ { date: '2026-08-01', v: 10 },
14
+ { date: '2026-08-02', v: 12 },
15
+ ]
16
+
17
+ describe('MultiLine — series.strokeOpacity dims the plotted stroke', () => {
18
+ test('the line path carries the configured stroke-opacity attribute', () => {
19
+ const series: ChartSeries<Row>[] = [
20
+ {
21
+ key: 'v',
22
+ label: 'V',
23
+ color: '#123456',
24
+ mark: 'line',
25
+ getValue: (d) => d.v,
26
+ strokeOpacity: 0.35,
27
+ },
28
+ ]
29
+
30
+ const html = renderToStaticMarkup(
31
+ <MultiLine<Row> data={rows} chartId="ml-opacity" getX={(d) => d.date} series={series} />,
32
+ )
33
+
34
+ expect(html).toContain('stroke="#123456"')
35
+ expect(html).toContain('stroke-opacity="0.35"')
36
+ })
37
+ })
@@ -1,6 +1,6 @@
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
5
  import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
6
6
  import { CartesianChart } from '../primitives/CartesianChart'
@@ -163,35 +163,27 @@ function MultiLineMarks<T>({
163
163
  }, [visible, data])
164
164
 
165
165
  // 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
166
+ // or when it is a companion of the highlighted series (its `parent` matches). `useCallback`
167
+ // (rather than a plain function) gives both a stable identity across renders where `highlighted`/
168
+ // `y2Scale` haven't changed, so `markerNodes` below can depend on THEM rather than the raw
169
+ // scale/highlighted values a closure would otherwise hide from the deps array.
170
+ const dimOpacity = useCallback(
171
+ (s: ChartSeries<T>): number =>
172
+ highlighted === null || s.key === highlighted || s.parent === highlighted ? 1 : 0.25,
173
+ [highlighted],
174
+ )
172
175
 
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
- })}
176
+ const scaleFor = useCallback(
177
+ (s: ChartSeries<T>) => (s.axis === 'right' && y2Scale !== null ? y2Scale : yScale),
178
+ [y2Scale, yScale],
179
+ )
193
180
 
194
- {visible.flatMap((s) => {
181
+ // Memoized for the same reason as `pointsBySeries` above — the shared cursor re-renders every
182
+ // mounted chart on every pointer frame, and this walks every visible series' points calling the
183
+ // consumer's `getMarker` each time otherwise.
184
+ const markerNodes = useMemo<ReactNode[]>(
185
+ () =>
186
+ visible.flatMap((s) => {
195
187
  const getMarker = s.getMarker
196
188
  if (!getMarker) return [] as ReactNode[]
197
189
  const scale = scaleFor(s)
@@ -231,7 +223,32 @@ function MultiLineMarks<T>({
231
223
  )
232
224
  }
233
225
  return markers
226
+ }),
227
+ [visible, pointsBySeries, xScale, getX, scaleFor, dimOpacity, markerShape],
228
+ )
229
+
230
+ return (
231
+ <>
232
+ {visible.map((s) => {
233
+ const valid = pointsBySeries.get(s.key) ?? []
234
+ if (valid.length === 0) return null
235
+ const scale = scaleFor(s)
236
+ return (
237
+ <LinePath<LinePt<T>>
238
+ key={`line-${s.key}`}
239
+ data={valid}
240
+ x={(p) => xScale(getX(p.__d)) ?? 0}
241
+ y={(p) => scale(p.__y)}
242
+ stroke={s.color}
243
+ strokeWidth={s.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
244
+ strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
245
+ strokeOpacity={dimOpacity(s) * (s.strokeOpacity ?? 1)}
246
+ curve={curveMonotoneX}
247
+ />
248
+ )
234
249
  })}
250
+
251
+ {markerNodes}
235
252
  </>
236
253
  )
237
254
  }
@@ -0,0 +1,37 @@
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 { describe, expect, test } from 'bun:test'
6
+ import { renderToStaticMarkup } from 'react-dom/server'
7
+ import { ZonedLine } from './ZonedLine'
8
+ import type { ChartSeries } from '../series'
9
+
10
+ type Row = { date: string; v: number }
11
+
12
+ const rows: Row[] = [
13
+ { date: '2026-08-01', v: 10 },
14
+ { date: '2026-08-02', v: 12 },
15
+ ]
16
+
17
+ describe('ZonedLine — series.strokeOpacity dims the plotted stroke', () => {
18
+ test('the line path carries the configured stroke-opacity attribute', () => {
19
+ const series: ChartSeries<Row>[] = [
20
+ {
21
+ key: 'v',
22
+ label: 'V',
23
+ color: '#654321',
24
+ mark: 'line',
25
+ getValue: (d) => d.v,
26
+ strokeOpacity: 0.5,
27
+ },
28
+ ]
29
+
30
+ const html = renderToStaticMarkup(
31
+ <ZonedLine<Row> data={rows} chartId="zl-opacity" getX={(d) => d.date} series={series} />,
32
+ )
33
+
34
+ expect(html).toContain('stroke="#654321"')
35
+ expect(html).toContain('stroke-opacity="0.5"')
36
+ })
37
+ })
@@ -228,6 +228,7 @@ function ZonedLineMarks<T>({
228
228
  stroke={primary.color}
229
229
  strokeWidth={primary.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
230
230
  strokeDasharray={primary.dash === 'dashed' ? VX.dashArray : undefined}
231
+ strokeOpacity={primary.strokeOpacity ?? 1}
231
232
  curve={curveMonotoneX}
232
233
  />
233
234
  )}
@@ -1,6 +1,8 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react'
1
2
  import { describe, expect, test } from 'bun:test'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
2
4
  import type { ChartSeries } from '../series'
3
- import { resolveAxisDomain } from './CartesianChart'
5
+ import { CartesianChart, resolveAxisDomain } from './CartesianChart'
4
6
 
5
7
  type Row = { date: string; a: number; b: number }
6
8
 
@@ -97,3 +99,62 @@ describe('resolveAxisDomain — degenerate input', () => {
97
99
  expect(max).toBeGreaterThan(min)
98
100
  })
99
101
  })
102
+
103
+ describe('AxisConfig.nice — threaded to BOTH the probe and the real scale', () => {
104
+ // A fixed, deliberately non-round domain: d3's `.ticks()` never produces a tick past the raw
105
+ // domain max (95.7) without `nice`, but WITH `nice` the scale first rounds its domain outward
106
+ // (e.g. to 100), so a tick at "100" can only appear on the `nice: true` render.
107
+ function renderChart(nice: boolean): string {
108
+ return renderToStaticMarkup(
109
+ <CartesianChart<Row>
110
+ data={rows}
111
+ chartId="nice-test"
112
+ getX={(d) => d.date}
113
+ series={[seriesFor('a')]}
114
+ y={{ domain: [0, 95.7], ticks: 5, format: (v) => String(v), nice }}
115
+ >
116
+ {() => null}
117
+ </CartesianChart>,
118
+ )
119
+ }
120
+
121
+ test('nice: false never paints a tick past the raw domain max', () => {
122
+ expect(renderChart(false)).not.toContain('>100<')
123
+ })
124
+
125
+ test('nice: true rounds the scale outward — a tick beyond the raw domain max appears', () => {
126
+ expect(renderChart(true)).toContain('>100<')
127
+ })
128
+ })
129
+
130
+ describe('tooltip extraRows — ctx.visible/ctx.hidden track legend toggling', () => {
131
+ test('toggling a series off via its legend entry updates the ctx the row reads', async () => {
132
+ render(
133
+ <CartesianChart<Row>
134
+ data={rows}
135
+ chartId="extra-rows-ctx"
136
+ getX={(d) => d.date}
137
+ series={both}
138
+ tooltip={{
139
+ extraRows: (_d, ctx) => (
140
+ <div data-testid="extra-ctx">
141
+ visible:{ctx.visible.map((s) => s.key).join(',')}|hidden:
142
+ {[...ctx.hidden].join(',')}
143
+ </div>
144
+ ),
145
+ }}
146
+ >
147
+ {() => null}
148
+ </CartesianChart>,
149
+ )
150
+
151
+ const slider = screen.getByRole('slider')
152
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
153
+
154
+ expect((await screen.findByTestId('extra-ctx')).textContent).toContain('visible:a,b|hidden:')
155
+
156
+ fireEvent.click(screen.getByRole('button', { name: 'b' }))
157
+
158
+ expect((await screen.findByTestId('extra-ctx')).textContent).toContain('visible:a|hidden:b')
159
+ })
160
+ })
@@ -53,6 +53,23 @@ export type AxisConfig<T> = {
53
53
  format?: (v: number) => string
54
54
  /** Horizontal grid rules. Default: on for the left axis, off for the right. */
55
55
  grid?: boolean
56
+ /**
57
+ * Round the scale's domain outward to nice tick values (d3's `scale.nice()`). Threaded to BOTH
58
+ * `probeAxisLabels` (measurement) and the real `scaleLinear` (rendering) — they must agree, or
59
+ * the measured margin is computed from ticks the axis never paints.
60
+ *
61
+ * Default `false`, deliberately: flipping the default would move the domain of every
62
+ * already-migrated chart. Opt in per axis.
63
+ */
64
+ nice?: boolean
65
+ }
66
+
67
+ /** Handed to `prependRows`/`extraRows` alongside the hovered datum — the same `visible`/`hidden`
68
+ * the plot itself draws from, so a hand-authored row can't structurally desync from legend
69
+ * toggling. */
70
+ export type CartesianTooltipRowContext<T> = {
71
+ visible: readonly ChartSeries<T>[]
72
+ hidden: ReadonlySet<string>
56
73
  }
57
74
 
58
75
  export type CartesianTooltipConfig<T> = {
@@ -65,10 +82,12 @@ export type CartesianTooltipConfig<T> = {
65
82
  follow?: boolean
66
83
  /** Right-aligned badge in the tooltip header (e.g. a status label). */
67
84
  label?: (d: T) => { text: string; color: string } | null
68
- /** Rows rendered BEFORE the derived per-series rows (a total, a context line). */
69
- prependRows?: (d: T) => ReactNode
70
- /** Rows appended after the derived per-series rows. */
71
- extraRows?: (d: T) => ReactNode
85
+ /** Rows rendered BEFORE the derived per-series rows (a total, a context line). The second
86
+ * argument carries the same `visible`/`hidden` the plot draws from, so a hand-authored row
87
+ * stays in sync with legend toggling instead of naming a series the plot no longer draws. */
88
+ prependRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
89
+ /** Rows appended after the derived per-series rows. Same `ctx` as {@link prependRows}. */
90
+ extraRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
72
91
  }
73
92
 
74
93
  /** Everything a mark renderer needs. Handed to `CartesianChart`'s child on every render. */
@@ -339,9 +358,10 @@ function CartesianPlot<T>({
339
358
  probeAxisLabels({
340
359
  domain: leftDomain,
341
360
  ticks: leftTicks,
361
+ nice: y?.nice ?? false,
342
362
  ...(y?.format !== undefined && { format: y.format }),
343
363
  }),
344
- [leftDomain, leftTicks, y?.format],
364
+ [leftDomain, leftTicks, y?.format, y?.nice],
345
365
  )
346
366
 
347
367
  const { labels: rightLabels, format: rightFormat } = useMemo(
@@ -351,9 +371,10 @@ function CartesianPlot<T>({
351
371
  : probeAxisLabels({
352
372
  domain: rightDomain,
353
373
  ticks: rightTicks,
374
+ nice: y2?.nice ?? false,
354
375
  ...(y2?.format !== undefined && { format: y2.format }),
355
376
  }),
356
- [rightDomain, rightTicks, y2?.format],
377
+ [rightDomain, rightTicks, y2?.format, y2?.nice],
357
378
  )
358
379
 
359
380
  const xLabels = useMemo(() => keys.map(formatX), [keys, formatX])
@@ -378,13 +399,24 @@ function CartesianPlot<T>({
378
399
  [keys, xMax],
379
400
  )
380
401
  const yScale = useMemo(
381
- () => scaleLinear<number>({ domain: leftDomain, range: [yMax, 0] }),
382
- [leftDomain, yMax],
402
+ () =>
403
+ scaleLinear<number>({
404
+ domain: leftDomain,
405
+ range: [yMax, 0],
406
+ ...(y?.nice === true && { nice: true }),
407
+ }),
408
+ [leftDomain, yMax, y?.nice],
383
409
  )
384
410
  const y2Scale = useMemo(
385
411
  () =>
386
- rightDomain === null ? null : scaleLinear<number>({ domain: rightDomain, range: [yMax, 0] }),
387
- [rightDomain, yMax],
412
+ rightDomain === null
413
+ ? null
414
+ : scaleLinear<number>({
415
+ domain: rightDomain,
416
+ range: [yMax, 0],
417
+ ...(y2?.nice === true && { nice: true }),
418
+ }),
419
+ [rightDomain, yMax, y2?.nice],
388
420
  )
389
421
 
390
422
  const tickValues = useMemo(
@@ -545,7 +577,7 @@ function CartesianPlot<T>({
545
577
  {...(badge !== null && { label: badge.text, labelColor: badge.color })}
546
578
  />
547
579
  <TooltipBody>
548
- {tooltipCfg?.prependRows?.(point)}
580
+ {tooltipCfg?.prependRows?.(point, { visible, hidden })}
549
581
  {rows.map((row) => (
550
582
  <TooltipRow
551
583
  key={row.key}
@@ -557,7 +589,7 @@ function CartesianPlot<T>({
557
589
  {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
558
590
  />
559
591
  ))}
560
- {tooltipCfg?.extraRows?.(point)}
592
+ {tooltipCfg?.extraRows?.(point, { visible, hidden })}
561
593
  </TooltipBody>
562
594
  </ChartTooltipFloat>
563
595
  )}
@@ -46,3 +46,33 @@ describe('ChartLegend note', () => {
46
46
  expect(markup).toContain('est.')
47
47
  })
48
48
  })
49
+
50
+ describe('ChartLegend strokeOpacity — the swatch cannot lie about a dimmed line', () => {
51
+ test('a line swatch honors a fractional strokeOpacity', () => {
52
+ const markup = render([{ ...BASE, shape: 'line', strokeOpacity: 0.4 }])
53
+ expect(markup).toContain('stroke-opacity="0.4"')
54
+ })
55
+
56
+ test('defaults to full opacity when unset', () => {
57
+ const markup = render([{ ...BASE, shape: 'line' }])
58
+ expect(markup).toContain('stroke-opacity="1"')
59
+ })
60
+
61
+ test('a folded MA-companion child swatch honors it too', () => {
62
+ const markup = render([
63
+ {
64
+ ...BASE,
65
+ children: [
66
+ {
67
+ key: 'ma',
68
+ label: '7d MA',
69
+ color: 'var(--vx-fill-2)',
70
+ shape: 'line',
71
+ strokeOpacity: 0.4,
72
+ },
73
+ ],
74
+ },
75
+ ])
76
+ expect(markup).toContain('stroke-opacity="0.4"')
77
+ })
78
+ })
@@ -13,6 +13,9 @@ export type LegendEntry = {
13
13
  dashed?: boolean
14
14
  /** bar swatch opacity — honored instead of a hardcoded value, so it cannot lie about the fill. */
15
15
  fillOpacity?: number
16
+ /** line/splitLine swatch stroke opacity — parity with `fillOpacity` above, so a dimmed
17
+ * companion line's swatch cannot lie about its own opacity either. */
18
+ strokeOpacity?: number
16
19
  /** Drives `groups` rendering (series → hairline divider → overlay → reference). */
17
20
  role?: SeriesRole
18
21
  /** Companions folded under this entry via `parent` (`deriveLegend`) — rendered as compact,
@@ -113,6 +116,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
113
116
  y2={7}
114
117
  stroke={item.color}
115
118
  strokeWidth={item.strokeWidth ?? 2.5}
119
+ strokeOpacity={item.strokeOpacity ?? 1}
116
120
  strokeDasharray={item.dashed ? VX.dashArray : undefined}
117
121
  />
118
122
  <line
@@ -122,6 +126,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
122
126
  y2={7}
123
127
  stroke={item.secondColor}
124
128
  strokeWidth={item.strokeWidth ?? 2.5}
129
+ strokeOpacity={item.strokeOpacity ?? 1}
125
130
  strokeDasharray={item.dashed ? VX.dashArray : undefined}
126
131
  />
127
132
  </svg>
@@ -173,6 +178,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
173
178
  y2={3}
174
179
  stroke={item.color}
175
180
  strokeWidth={item.strokeWidth ?? 3}
181
+ strokeOpacity={item.strokeOpacity ?? 1}
176
182
  strokeLinecap="round"
177
183
  strokeDasharray={item.dashed ? VX.dashArray : undefined}
178
184
  />
@@ -222,6 +228,7 @@ function LegendChildSwatch({ item }: { item: LegendEntry }) {
222
228
  y2={5}
223
229
  stroke={item.color}
224
230
  strokeWidth={item.strokeWidth ?? 2}
231
+ strokeOpacity={item.strokeOpacity ?? 1}
225
232
  strokeDasharray={item.dashed ? VX.dashArray : undefined}
226
233
  />
227
234
  </svg>
@@ -60,4 +60,64 @@ describe('XZoneRects', () => {
60
60
  test('an empty zones array renders no rects', () => {
61
61
  expect(render([])).not.toContain('<rect')
62
62
  })
63
+
64
+ // Mirrors XZoneRects' own bound resolution exactly (same clamp + sign math), so expectations
65
+ // never drift from the implementation's floating-point arithmetic.
66
+ function edgeBound(key: string, sign: 1 | -1): number {
67
+ const step = xScale.step()
68
+ const [rangeStart, rangeEnd] = xScale.range() as [number, number]
69
+ const rangeMin = Math.min(rangeStart, rangeEnd)
70
+ const rangeMax = Math.max(rangeStart, rangeEnd)
71
+ const center = xScale(key) as number
72
+ return Math.min(Math.max(center + sign * (step / 2), rangeMin), rangeMax)
73
+ }
74
+
75
+ test('align: "edge" widens a two-key band by exactly one step over center mode', () => {
76
+ const step = xScale.step()
77
+ const centerFrom = xScale('b') as number
78
+ const centerTo = xScale('c') as number
79
+ const centerMarkup = render([{ from: 'b', to: 'c', fill: 'red' }])
80
+ const edgeMarkup = render([{ from: 'b', to: 'c', fill: 'red', align: 'edge' }])
81
+ expect(centerMarkup).toContain(`width="${centerTo - centerFrom}"`)
82
+ const edgeFrom = edgeBound('b', -1)
83
+ const edgeTo = edgeBound('c', 1)
84
+ expect(edgeMarkup).toContain(`x="${edgeFrom}"`)
85
+ expect(edgeMarkup).toContain(`width="${edgeTo - edgeFrom}"`)
86
+ expect(edgeTo - edgeFrom).toBeCloseTo(centerTo - centerFrom + step, 10)
87
+ })
88
+
89
+ test('align: "edge" with from === to renders exactly one step wide, where center renders nothing', () => {
90
+ const step = xScale.step()
91
+ const centerMarkup = render([{ from: 'b', to: 'b', fill: 'red' }])
92
+ const edgeMarkup = render([{ from: 'b', to: 'b', fill: 'red', align: 'edge' }])
93
+ expect(centerMarkup).not.toContain('<rect')
94
+ const edgeFrom = edgeBound('b', -1)
95
+ const edgeTo = edgeBound('b', 1)
96
+ expect(edgeMarkup).toContain(`x="${edgeFrom}"`)
97
+ expect(edgeMarkup).toContain(`width="${edgeTo - edgeFrom}"`)
98
+ expect(edgeTo - edgeFrom).toBeCloseTo(step, 10)
99
+ })
100
+
101
+ test('an unknown key is skipped in both align modes', () => {
102
+ const centerMarkup = render([{ from: 'not-in-domain', to: 'c', fill: 'red' }])
103
+ const edgeMarkup = render([{ from: 'not-in-domain', to: 'c', fill: 'red', align: 'edge' }])
104
+ expect(centerMarkup).not.toContain('<rect')
105
+ expect(edgeMarkup).not.toContain('<rect')
106
+ })
107
+
108
+ test('align: "edge" clamps the widened left edge at the first sample into the plot range', () => {
109
+ const [rangeStart] = xScale.range() as [number, number]
110
+ const markup = render([{ from: 'a', to: 'a', fill: 'red', align: 'edge' }])
111
+ const edgeTo = edgeBound('a', 1)
112
+ expect(markup).toContain(`x="${rangeStart}"`)
113
+ expect(markup).toContain(`width="${edgeTo - rangeStart}"`)
114
+ })
115
+
116
+ test('align: "edge" clamps the widened right edge at the last sample into the plot range', () => {
117
+ const [, rangeEnd] = xScale.range() as [number, number]
118
+ const markup = render([{ from: 'd', to: 'd', fill: 'red', align: 'edge' }])
119
+ const edgeFrom = edgeBound('d', -1)
120
+ expect(markup).toContain(`x="${edgeFrom}"`)
121
+ expect(markup).toContain(`width="${rangeEnd - edgeFrom}"`)
122
+ })
63
123
  })