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,11 +5,12 @@ import { useMemo, useRef, useState } from 'react'
5
5
  import type { ReactNode } from 'react'
6
6
  import { VX } from '../../tokens'
7
7
  import type { ChartMargin } from '../../tokens'
8
+ import type { CursorResolution } from '../cursor/resolve'
8
9
  import { useChartCursor } from '../hooks/useChartCursor'
9
10
  import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
10
11
  import { deriveTooltipRows } from '../series'
11
12
  import type { ChartLegendConfig, ChartSeries } from '../series'
12
- import { padAutoLower } from '../utils/domain'
13
+ import { padAutoLower, padAutoUpper } from '../utils/domain'
13
14
  import { fmtAxisDate } from '../utils/format'
14
15
  import { smartTicks, smartTicksEvery } from '../utils/ticks'
15
16
  import { AxisBottomDate, AxisLeftNumeric, AxisRightNumeric } from './Axes'
@@ -41,7 +42,11 @@ export type AxisConfig<T> = {
41
42
  | [number, number]
42
43
  | 'auto'
43
44
  | ((data: readonly T[], visible: readonly ChartSeries<T>[]) => [number, number])
44
- /** When 'auto': the upper bound is at least this. */
45
+ /**
46
+ * When 'auto': the raw upper bound is at least this, and padding is applied AFTER — mirroring
47
+ * `autoMinCeil`, which clamps first and pads second. A floor that padded before the clamp used
48
+ * to land the axis top exactly on the floor value with zero headroom.
49
+ */
45
50
  autoMaxFloor?: number
46
51
  /** When 'auto': the lower bound is at most this. Default 0 (forced zero baseline). `Infinity`
47
52
  * pads down from the raw data minimum instead. */
@@ -53,6 +58,23 @@ export type AxisConfig<T> = {
53
58
  format?: (v: number) => string
54
59
  /** Horizontal grid rules. Default: on for the left axis, off for the right. */
55
60
  grid?: boolean
61
+ /**
62
+ * Round the scale's domain outward to nice tick values (d3's `scale.nice()`). Threaded to BOTH
63
+ * `probeAxisLabels` (measurement) and the real `scaleLinear` (rendering) — they must agree, or
64
+ * the measured margin is computed from ticks the axis never paints.
65
+ *
66
+ * Default `false`, deliberately: flipping the default would move the domain of every
67
+ * already-migrated chart. Opt in per axis.
68
+ */
69
+ nice?: boolean
70
+ }
71
+
72
+ /** Handed to `prependRows`/`extraRows` alongside the hovered datum — the same `visible`/`hidden`
73
+ * the plot itself draws from, so a hand-authored row can't structurally desync from legend
74
+ * toggling. */
75
+ export type CartesianTooltipRowContext<T> = {
76
+ visible: readonly ChartSeries<T>[]
77
+ hidden: ReadonlySet<string>
56
78
  }
57
79
 
58
80
  export type CartesianTooltipConfig<T> = {
@@ -65,10 +87,20 @@ export type CartesianTooltipConfig<T> = {
65
87
  follow?: boolean
66
88
  /** Right-aligned badge in the tooltip header (e.g. a status label). */
67
89
  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
90
+ /**
91
+ * Overrides the tooltip header's date text. Default: `TooltipHeader`'s own `fmtTooltipDate`
92
+ * behavior, unchanged. `fmtTooltipDate` regexes `YYYY-MM-DD` out of the domain key and builds a
93
+ * LOCAL `Date` from it — a UTC ISO key then names the wrong day next to `formatX`, the tooltip
94
+ * badge, and every sibling chart, which all resolve locally. Receives the raw `getX` key
95
+ * alongside the hovered datum, so a caller can format from either.
96
+ */
97
+ formatHeader?: (key: string, d: T) => string
98
+ /** Rows rendered BEFORE the derived per-series rows (a total, a context line). The second
99
+ * argument carries the same `visible`/`hidden` the plot draws from, so a hand-authored row
100
+ * stays in sync with legend toggling instead of naming a series the plot no longer draws. */
101
+ prependRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
102
+ /** Rows appended after the derived per-series rows. Same `ctx` as {@link prependRows}. */
103
+ extraRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
72
104
  }
73
105
 
74
106
  /** Everything a mark renderer needs. Handed to `CartesianChart`'s child on every render. */
@@ -130,6 +162,14 @@ export type CartesianChartProps<T> = {
130
162
  series: ChartSeries<T>,
131
163
  visible: readonly ChartSeries<T>[],
132
164
  ) => number | null
165
+ /**
166
+ * How a sibling chart's broadcast cursor key resolves against THIS chart's points. Default
167
+ * `'nearest'`, correct when `getX` returns an instant. Pass `'leading'` when it returns a
168
+ * bucket's leading edge (a weekly series keyed by its Monday): under `'nearest'` a hover landing
169
+ * in the back half of a bucket resolves to the FOLLOWING bucket, so the shared crosshair sits
170
+ * one column right of the data the reader is pointing at.
171
+ */
172
+ cursorResolution?: CursorResolution
133
173
  ariaLabel?: string
134
174
  isPending?: boolean
135
175
  /** Draw ONLY the marks. Everything around them is the primitive's job. */
@@ -177,11 +217,10 @@ export function resolveAxisDomain<T>(
177
217
 
178
218
  const pad = cfg?.autoPad ?? DEFAULT_AUTO_PAD
179
219
  const ceil = cfg?.autoMinCeil ?? 0
180
- // Pad AWAY from zero, never by multiplying: `max * pad` on an all-negative series moves the
181
- // upper bound further negative (`-5 * 1.1 = -5.5`), i.e. past the data, clipping the largest
182
- // value out of the plot. Mirrors `padAutoLower`'s reasoning for the lower bound.
183
- const paddedMax = max > 0 ? max * pad : max < 0 ? max / pad : 0
184
- const upper = Math.max(paddedMax, cfg?.autoMaxFloor ?? -Infinity)
220
+ // Symmetric with the lower bound: clamp the RAW value against the floor/ceiling first, then pad
221
+ // once, away from zero. Padding before the clamp (the old law) lands a floored axis top exactly
222
+ // on the floor with zero headroom — a target line pinned there sits glued to the plot edge.
223
+ const upper = padAutoUpper(Math.max(max, cfg?.autoMaxFloor ?? -Infinity), pad)
185
224
  const lower = padAutoLower(Math.min(min, ceil), pad)
186
225
  // A flat series (every value identical, the all-zero case included) collapses to `[n, n]`,
187
226
  // which is a scale with no extent: every point maps to the same pixel and the line renders as a
@@ -222,6 +261,7 @@ export function CartesianChart<T>({
222
261
  tooltip,
223
262
  margin: marginOverride,
224
263
  cursorValue,
264
+ cursorResolution,
225
265
  ariaLabel,
226
266
  isPending,
227
267
  children,
@@ -255,6 +295,7 @@ export function CartesianChart<T>({
255
295
  {...(tooltip !== undefined && { tooltip })}
256
296
  {...(marginOverride !== undefined && { marginOverride })}
257
297
  {...(cursorValue !== undefined && { cursorValue })}
298
+ {...(cursorResolution !== undefined && { cursorResolution })}
258
299
  {...(ariaLabel !== undefined && { ariaLabel })}
259
300
  plot={plot}
260
301
  highlighted={highlighted}
@@ -292,6 +333,7 @@ function CartesianPlot<T>({
292
333
  tooltip,
293
334
  marginOverride,
294
335
  cursorValue,
336
+ cursorResolution,
295
337
  ariaLabel,
296
338
  plot,
297
339
  highlighted,
@@ -339,9 +381,10 @@ function CartesianPlot<T>({
339
381
  probeAxisLabels({
340
382
  domain: leftDomain,
341
383
  ticks: leftTicks,
384
+ nice: y?.nice ?? false,
342
385
  ...(y?.format !== undefined && { format: y.format }),
343
386
  }),
344
- [leftDomain, leftTicks, y?.format],
387
+ [leftDomain, leftTicks, y?.format, y?.nice],
345
388
  )
346
389
 
347
390
  const { labels: rightLabels, format: rightFormat } = useMemo(
@@ -351,9 +394,10 @@ function CartesianPlot<T>({
351
394
  : probeAxisLabels({
352
395
  domain: rightDomain,
353
396
  ticks: rightTicks,
397
+ nice: y2?.nice ?? false,
354
398
  ...(y2?.format !== undefined && { format: y2.format }),
355
399
  }),
356
- [rightDomain, rightTicks, y2?.format],
400
+ [rightDomain, rightTicks, y2?.format, y2?.nice],
357
401
  )
358
402
 
359
403
  const xLabels = useMemo(() => keys.map(formatX), [keys, formatX])
@@ -378,13 +422,24 @@ function CartesianPlot<T>({
378
422
  [keys, xMax],
379
423
  )
380
424
  const yScale = useMemo(
381
- () => scaleLinear<number>({ domain: leftDomain, range: [yMax, 0] }),
382
- [leftDomain, yMax],
425
+ () =>
426
+ scaleLinear<number>({
427
+ domain: leftDomain,
428
+ range: [yMax, 0],
429
+ ...(y?.nice === true && { nice: true }),
430
+ }),
431
+ [leftDomain, yMax, y?.nice],
383
432
  )
384
433
  const y2Scale = useMemo(
385
434
  () =>
386
- rightDomain === null ? null : scaleLinear<number>({ domain: rightDomain, range: [yMax, 0] }),
387
- [rightDomain, yMax],
435
+ rightDomain === null
436
+ ? null
437
+ : scaleLinear<number>({
438
+ domain: rightDomain,
439
+ range: [yMax, 0],
440
+ ...(y2?.nice === true && { nice: true }),
441
+ }),
442
+ [rightDomain, yMax, y2?.nice],
388
443
  )
389
444
 
390
445
  const tickValues = useMemo(
@@ -398,6 +453,7 @@ function CartesianPlot<T>({
398
453
  getKey: getX,
399
454
  xScale,
400
455
  marginLeft: margin.left,
456
+ ...(cursorResolution !== undefined && { resolution: cursorResolution }),
401
457
  })
402
458
 
403
459
  const svgRef = useRef<SVGSVGElement>(null)
@@ -406,6 +462,7 @@ function CartesianPlot<T>({
406
462
  const point = cursor.point
407
463
  const cursorX = point === null ? null : (xScale(getX(point)) ?? null)
408
464
  const badge = tooltipCfg?.label === undefined || point === null ? null : tooltipCfg.label(point)
465
+ const formatHeader = tooltipCfg?.formatHeader
409
466
 
410
467
  // Each series falls back to ITS OWN axis's tick format, so a right-axis value is never
411
468
  // formatted with the left axis's rules (an explicit per-series `formatValue` still wins).
@@ -542,10 +599,13 @@ function CartesianPlot<T>({
542
599
  <ChartTooltipFloat anchor={tooltipAnchor}>
543
600
  <TooltipHeader
544
601
  date={getX(point)}
602
+ {...(formatHeader !== undefined && {
603
+ format: (key: string) => formatHeader(key, point),
604
+ })}
545
605
  {...(badge !== null && { label: badge.text, labelColor: badge.color })}
546
606
  />
547
607
  <TooltipBody>
548
- {tooltipCfg?.prependRows?.(point)}
608
+ {tooltipCfg?.prependRows?.(point, { visible, hidden })}
549
609
  {rows.map((row) => (
550
610
  <TooltipRow
551
611
  key={row.key}
@@ -557,7 +617,7 @@ function CartesianPlot<T>({
557
617
  {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
558
618
  />
559
619
  ))}
560
- {tooltipCfg?.extraRows?.(point)}
620
+ {tooltipCfg?.extraRows?.(point, { visible, hidden })}
561
621
  </TooltipBody>
562
622
  </ChartTooltipFloat>
563
623
  )}
@@ -13,9 +13,11 @@
13
13
  * to the DOM harness would only be worth it if a future assertion here needed a real measured size
14
14
  * (a live `ResizeObserver` reading) rather than this SSR fallback rect.
15
15
  */
16
+ import { render } from '@testing-library/react'
16
17
  import { describe, expect, test } from 'bun:test'
17
18
  import { renderToStaticMarkup } from 'react-dom/server'
18
19
  import { ChartFrame } from './ChartFrame'
20
+ import { HoverOverlay } from './HoverOverlay'
19
21
  import type { SeriesStyle } from '../series'
20
22
 
21
23
  const series: SeriesStyle[] = [{ key: 'a', label: 'Series A', color: '#000', mark: 'line' }]
@@ -66,6 +68,46 @@ describe('a non-pending ChartFrame is unaffected — the legend and body both re
66
68
  })
67
69
  })
68
70
 
71
+ describe('ariaLabel: the label is announced WITHOUT swallowing the interactive slider', () => {
72
+ // The regression this guards: `role="img"` marks every descendant presentational per the ARIA
73
+ // spec, so a screen reader would announce the label and then never expose `HoverOverlay`'s
74
+ // `role="slider"` at all — the keyboard-scrubbable affordance becomes unreachable with no error
75
+ // anywhere. `role="group"` announces the same label while keeping descendants in the tree.
76
+ test('the outer container is role="group" with the label, never role="img"', () => {
77
+ const { container } = render(
78
+ <ChartFrame series={series} ariaLabel="Revenue over time" legend={false}>
79
+ {() => <svg />}
80
+ </ChartFrame>,
81
+ )
82
+ const outer = container.firstElementChild as HTMLElement
83
+ expect(outer.getAttribute('role')).toBe('group')
84
+ expect(outer.getAttribute('aria-label')).toBe('Revenue over time')
85
+ expect(container.querySelector('[role="img"]')).toBeNull()
86
+ })
87
+
88
+ test('the slider stays a reachable descendant of the labeled container', () => {
89
+ const { container } = render(
90
+ <ChartFrame series={series} ariaLabel="Revenue over time" legend={false}>
91
+ {() => (
92
+ <svg>
93
+ <HoverOverlay
94
+ width={100}
95
+ height={100}
96
+ onMove={() => {}}
97
+ onLeave={() => {}}
98
+ onKeyDown={() => {}}
99
+ />
100
+ </svg>
101
+ )}
102
+ </ChartFrame>,
103
+ )
104
+ const outer = container.firstElementChild as HTMLElement
105
+ expect(outer.getAttribute('role')).toBe('group')
106
+ const slider = outer.querySelector('[role="slider"]')
107
+ expect(slider).not.toBeNull()
108
+ })
109
+ })
110
+
69
111
  const twoSeries: SeriesStyle[] = [
70
112
  { key: 'a', label: 'Series A', color: '#000', mark: 'line' },
71
113
  { key: 'b', label: 'Series B', color: '#111', mark: 'bar' },
@@ -53,9 +53,16 @@ export type ChartFrameProps = {
53
53
  */
54
54
  isPending?: boolean
55
55
  /**
56
- * Accessible text alternative for the chart, applied as `aria-label` (+ `role="img"`) on the
56
+ * Accessible text alternative for the chart, applied as `aria-label` (+ `role="group"`) on the
57
57
  * outer container so screen readers announce something other than an unlabeled graphic. Every
58
58
  * kind composing `ChartFrame` should accept and forward this from its own props.
59
+ *
60
+ * MUST stay `role="group"`, never `role="img"`. Per the ARIA spec, every descendant of an
61
+ * `role="img"` element is presentational, which erases the `HoverOverlay`'s `role="slider"` from
62
+ * the accessibility tree entirely — a screen reader announces the label and then the
63
+ * keyboard-scrubbable slider underneath it is simply unreachable, silently, with no error
64
+ * anywhere. `role="group"` announces the same label while keeping descendants exposed. Do not
65
+ * "simplify" this back to `img` — it looks like a no-op refactor and it is not.
59
66
  */
60
67
  ariaLabel?: string
61
68
  /** Draw the SVG marks given the plot rect that already excludes the legend band, plus the set
@@ -189,7 +196,7 @@ export function ChartFrame({
189
196
  <div
190
197
  ref={containerRef}
191
198
  style={outerStyle(fill, vertical)}
192
- {...(ariaLabel !== undefined && { role: 'img', 'aria-label': ariaLabel })}
199
+ {...(ariaLabel !== undefined && { role: 'group', 'aria-label': ariaLabel })}
193
200
  {...(isPending && { 'aria-busy': 'true' })}
194
201
  >
195
202
  {legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
@@ -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>
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `TooltipHeader` — the `format` seam that lets a caller override `fmtTooltipDate`. Added because
3
+ * `fmtTooltipDate` regexes `YYYY-MM-DD` out of the key and builds a LOCAL `Date`, so a UTC ISO
4
+ * domain key names the wrong day next to `formatX`/the tooltip badge, which both resolve locally.
5
+ */
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { renderToStaticMarkup } from 'react-dom/server'
8
+ import { TooltipHeader } from './ChartTooltip'
9
+
10
+ describe('TooltipHeader — format prop', () => {
11
+ test('defaults to fmtTooltipDate when omitted', () => {
12
+ const html = renderToStaticMarkup(<TooltipHeader date="2026-04-21" />)
13
+ expect(html).toContain('Tue Apr 21 2026')
14
+ })
15
+
16
+ test('a custom format overrides fmtTooltipDate entirely', () => {
17
+ const html = renderToStaticMarkup(
18
+ <TooltipHeader date="2026-04-21" format={(d) => `custom:${d}`} />,
19
+ )
20
+ expect(html).toContain('custom:2026-04-21')
21
+ expect(html).not.toContain('Tue Apr 21 2026')
22
+ })
23
+ })
@@ -26,10 +26,16 @@ export function TooltipHeader({
26
26
  date,
27
27
  label,
28
28
  labelColor,
29
+ format = fmtTooltipDate,
29
30
  }: {
30
31
  date: string
31
32
  label?: string
32
33
  labelColor?: string
34
+ /** Overrides `fmtTooltipDate` for the header text. Default: today's `fmtTooltipDate` behavior,
35
+ * unchanged. The seam exists because `fmtTooltipDate` regexes `YYYY-MM-DD` out of the key and
36
+ * builds a LOCAL `Date`, so a UTC ISO domain key names the wrong day next to `formatX`/the
37
+ * tooltip badge, which both resolve locally — a caller with a UTC key needs to supply its own. */
38
+ format?: (date: string) => string
33
39
  }) {
34
40
  return (
35
41
  <div
@@ -50,7 +56,7 @@ export function TooltipHeader({
50
56
  color: VX.ink,
51
57
  }}
52
58
  >
53
- {fmtTooltipDate(date)}
59
+ {format(date)}
54
60
  </span>
55
61
  {label !== undefined && (
56
62
  <span
@@ -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
  })
@@ -13,6 +13,50 @@ export type XZoneSpec = {
13
13
  /** Right edge — a domain key produced by the kind's `getX`. Omit for "end of the plot area". */
14
14
  to?: string
15
15
  fill: string
16
+ /**
17
+ * `'center'` (default, unchanged behaviour) resolves a present bound to `xScale(key)` — the
18
+ * point's own center, so a band reads "from this sample to that sample" rather than
19
+ * edge-to-edge. `'edge'` resolves a present `from` to `xScale(from) - step/2` and a present `to`
20
+ * to `xScale(to) + step/2` (`step` read from `xScale.step()`), so the band covers both terminal
21
+ * slots in full — a two-key band widens by exactly one step, and `from === to` renders one step
22
+ * wide instead of being skipped as degenerate. An omitted `from`/`to` still resolves to the plot
23
+ * edge (`xScale.range()[0]`/`[1]`) in both modes, unshifted — omission means "the plot's own
24
+ * edge", not "the first/last sample's edge". A resolved `'edge'` bound is clamped into the plot
25
+ * range so a band anchored at the first or last sample cannot paint outside the plot area.
26
+ */
27
+ align?: 'center' | 'edge'
28
+ }
29
+
30
+ function clamp(value: number, min: number, max: number): number {
31
+ return Math.min(Math.max(value, min), max)
32
+ }
33
+
34
+ /**
35
+ * Resolves one zone bound (`from` or `to`) to a pixel x. `edgeSign` is `-1` for `from` (subtract
36
+ * half a step) and `+1` for `to` (add half a step) — only consulted in `'edge'` mode.
37
+ */
38
+ function resolveBound({
39
+ key,
40
+ omittedEdge,
41
+ xScale,
42
+ align,
43
+ edgeSign,
44
+ rangeMin,
45
+ rangeMax,
46
+ }: {
47
+ key: string | undefined
48
+ omittedEdge: number
49
+ xScale: PointScale
50
+ align: 'center' | 'edge'
51
+ edgeSign: -1 | 1
52
+ rangeMin: number
53
+ rangeMax: number
54
+ }): number | undefined {
55
+ if (key === undefined) return omittedEdge
56
+ const center = xScale(key)
57
+ if (center === undefined) return undefined
58
+ if (align === 'center') return center
59
+ return clamp(center + edgeSign * (xScale.step() / 2), rangeMin, rangeMax)
16
60
  }
17
61
 
18
62
  /**
@@ -20,18 +64,20 @@ export type XZoneSpec = {
20
64
  * <rect> per zone, spanning y=[0, height] and x=[scale(from), scale(to)]. The x analog of
21
65
  * `ZoneRects`, for a `scalePoint` x-axis instead of a `scaleLinear` y-axis.
22
66
  *
23
- * An omitted `from` resolves to `0` (start of the plot area); an omitted `to` resolves to
24
- * `xScale.range()[1]` (the plot's right edge). A present key resolves to `xScale(key)` — the
25
- * POINT CENTER, so a band reads as "from this sample to that sample", not edge-to-edge.
67
+ * Bound resolution depends on `XZoneSpec.align` (see its JSDoc for the full `'center'`/`'edge'`
68
+ * contract) — default `'center'` resolves a present key to the point's own center; `'edge'`
69
+ * widens by half a step at each present bound so the band covers the terminal slots in full.
26
70
  *
27
71
  * A key that is not in the scale's domain skips that band entirely (renders nothing for it) —
28
- * it is NEVER clamped to a plot edge. Clamping would silently paint a band across the whole
29
- * chart for a typo'd or stale key, which is the exact class of quiet lie the repo's "nothing to
30
- * draw is two different states" doctrine rejects: an unknown key must read as "not drawn", not
31
- * as "the whole plot".
72
+ * it is NEVER clamped to a plot edge, in either mode. Clamping an unknown key would silently
73
+ * paint a band across the whole chart for a typo'd or stale key, which is the exact class of
74
+ * quiet lie the repo's "nothing to draw is two different states" doctrine rejects: an unknown key
75
+ * must read as "not drawn", not as "the whole plot".
32
76
  *
33
77
  * A degenerate resolved range (right edge <= left edge, e.g. an inverted `from`/`to` pair) is
34
- * also skipped — no negative-width rect.
78
+ * also skipped — no negative-width rect. In `'center'` mode `from === to` is degenerate (skipped,
79
+ * today's behaviour); in `'edge'` mode it is NOT — the half-step widening on each side always
80
+ * produces a positive-width, one-step-wide rect.
35
81
  */
36
82
  export function XZoneRects({
37
83
  zones,
@@ -47,8 +93,27 @@ export function XZoneRects({
47
93
  <>
48
94
  {zones.map((z, i) => {
49
95
  const [rangeStart, rangeEnd] = xScale.range() as [number, number]
50
- const xFrom = z.from === undefined ? rangeStart : xScale(z.from)
51
- const xTo = z.to === undefined ? rangeEnd : xScale(z.to)
96
+ const rangeMin = Math.min(rangeStart, rangeEnd)
97
+ const rangeMax = Math.max(rangeStart, rangeEnd)
98
+ const align = z.align ?? 'center'
99
+ const xFrom = resolveBound({
100
+ key: z.from,
101
+ omittedEdge: rangeStart,
102
+ xScale,
103
+ align,
104
+ edgeSign: -1,
105
+ rangeMin,
106
+ rangeMax,
107
+ })
108
+ const xTo = resolveBound({
109
+ key: z.to,
110
+ omittedEdge: rangeEnd,
111
+ xScale,
112
+ align,
113
+ edgeSign: 1,
114
+ rangeMin,
115
+ rangeMax,
116
+ })
52
117
  if (xFrom === undefined || xTo === undefined) return null
53
118
  if (xTo <= xFrom) return null
54
119
  return (
@@ -58,4 +58,55 @@ describe('deriveTooltipRows', () => {
58
58
  )
59
59
  expect(rows[0]?.value).toBe('42%')
60
60
  })
61
+
62
+ test('formatValue receives the hovered datum alongside the value', () => {
63
+ type Set = { v: number; reps: number }
64
+ const rows = deriveTooltipRows(
65
+ [
66
+ {
67
+ key: 'set',
68
+ label: 'Set',
69
+ color: '#000',
70
+ mark: 'bar' as const,
71
+ getValue: (d: Set) => d.v,
72
+ formatValue: (v: number, d: Set) => `${v} kg (${d.reps} reps)`,
73
+ },
74
+ ],
75
+ { v: 97.5, reps: 3 },
76
+ fmt,
77
+ )
78
+ expect(rows[0]?.value).toBe('97.5 kg (3 reps)')
79
+ })
80
+
81
+ test('the fallback formatter (single-arg) still works once formatValue is 2-arg typed', () => {
82
+ const rows = deriveTooltipRows([{ ...base, key: 'a', label: 'A' }], { v: 5 }, fmt)
83
+ expect(rows[0]?.value).toBe('5')
84
+ })
85
+ })
86
+
87
+ describe('strokeOpacity — a mark property, not a tooltip property', () => {
88
+ test('reaches the derived legend entry', () => {
89
+ const [entry] = deriveLegend([{ ...baseSeries, strokeOpacity: 0.4 }])
90
+ expect(entry?.strokeOpacity).toBe(0.4)
91
+ })
92
+
93
+ test('is absent from the legend entry when unset', () => {
94
+ const [entry] = deriveLegend([baseSeries])
95
+ expect(entry).not.toHaveProperty('strokeOpacity')
96
+ })
97
+
98
+ test('never reaches TooltipRowData — the row is a 12px value chip, not a dimmed mark', () => {
99
+ const rows = deriveTooltipRows(
100
+ [
101
+ {
102
+ ...baseSeries,
103
+ strokeOpacity: 0.4,
104
+ getValue: () => 10,
105
+ },
106
+ ],
107
+ {},
108
+ (v) => String(v),
109
+ )
110
+ expect(rows[0]).not.toHaveProperty('strokeOpacity')
111
+ })
61
112
  })