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
@@ -22,6 +22,27 @@ export function parseKey(key: string): number | null {
22
22
  return Number.isNaN(t) ? null : t
23
23
  }
24
24
 
25
+ /**
26
+ * How a foreign key maps onto a chart's own points.
27
+ *
28
+ * `'nearest'` (default) treats every key as a POINT — the foreign key resolves to whichever own
29
+ * point sits closest to it. Correct when a chart's x-domain is a set of instants.
30
+ *
31
+ * `'leading'` treats every key as the LEADING EDGE of a bucket covering `[key, nextKey)` — the
32
+ * foreign key resolves to the LAST own key `<=` it. Use this when a chart's keys are bucket
33
+ * starts (a weekly series keyed by the Monday of each week, a monthly series keyed by the 1st):
34
+ * under `'nearest'`, a target landing in the back half of a bucket resolves to the FOLLOWING
35
+ * bucket instead of the one it's actually inside, and the shared crosshair lands one column off
36
+ * for every back-half hover.
37
+ *
38
+ * The two modes differ at the DOMAIN EDGES too, and deliberately: `'nearest'` tolerates one step
39
+ * past each end (the target is an instant, so the closest point is still a defensible answer),
40
+ * while `'leading'` bounds strictly to `[first, last + step)` — outside that range no bucket
41
+ * CONTAINS the key, and answering with the nearest one would paint a crosshair on a bucket that
42
+ * provably excludes it.
43
+ */
44
+ export type CursorResolution = 'nearest' | 'leading'
45
+
25
46
  export type DomainIndex<T> = {
26
47
  byKey: Map<string, T>
27
48
  /** Parsed positions, ascending. Empty when the domain isn't parseable. */
@@ -30,10 +51,16 @@ export type DomainIndex<T> = {
30
51
  points: T[]
31
52
  /** Median spacing — the tolerance within which a foreign key still resolves. */
32
53
  step: number
54
+ /** The resolution strategy this index was built for. */
55
+ resolution: CursorResolution
33
56
  }
34
57
 
35
58
  /** Build the lookup structure once per `data` identity. */
36
- export function buildDomainIndex<T>(data: readonly T[], getKey: (d: T) => string): DomainIndex<T> {
59
+ export function buildDomainIndex<T>(
60
+ data: readonly T[],
61
+ getKey: (d: T) => string,
62
+ resolution: CursorResolution = 'nearest',
63
+ ): DomainIndex<T> {
37
64
  const byKey = new Map<string, T>()
38
65
  const parsed: { value: number; point: T }[] = []
39
66
  let parseable = true
@@ -48,7 +75,9 @@ export function buildDomainIndex<T>(data: readonly T[], getKey: (d: T) => string
48
75
 
49
76
  // Under two parseable points there is no step to reason about, so nearest-match would be
50
77
  // unbounded — fall back to exact match.
51
- if (!parseable || parsed.length < 2) return { byKey, values: [], points: [], step: 0 }
78
+ if (!parseable || parsed.length < 2) {
79
+ return { byKey, values: [], points: [], step: 0, resolution }
80
+ }
52
81
 
53
82
  parsed.sort((a, b) => a.value - b.value)
54
83
  const values = parsed.map((p) => p.value)
@@ -60,15 +89,39 @@ export function buildDomainIndex<T>(data: readonly T[], getKey: (d: T) => string
60
89
  }
61
90
  diffs.sort((a, b) => a - b)
62
91
 
63
- return { byKey, values, points, step: diffs[Math.floor(diffs.length / 2)] ?? 0 }
92
+ return { byKey, values, points, step: diffs[Math.floor(diffs.length / 2)] ?? 0, resolution }
93
+ }
94
+
95
+ /** `'leading'` resolution: the last own position `<= target`. The caller has already rejected
96
+ * anything outside `[first, last + step)`, so a containing bucket is guaranteed to exist. */
97
+ function resolveLeading<T>(index: DomainIndex<T>, target: number): T {
98
+ let lo = 0
99
+ let hi = index.values.length - 1
100
+ let result = 0
101
+ while (lo <= hi) {
102
+ const mid = (lo + hi) >> 1
103
+ if ((index.values[mid] as number) <= target) {
104
+ result = mid
105
+ lo = mid + 1
106
+ } else {
107
+ hi = mid - 1
108
+ }
109
+ }
110
+ return index.points[result] as T
64
111
  }
65
112
 
66
113
  /**
67
- * The own point a broadcast `key` maps to: exact match first, else the nearest parsed position
68
- * within one domain step. Out of range → null, so charts over unrelated domains never paint a
69
- * cursor for each other — which is what makes "shared by default" safe.
114
+ * The own point a broadcast `key` maps to: exact match first, else — per `index.resolution` — the
115
+ * nearest parsed position or the last position at/before the target, both within one domain step.
116
+ * Out of range → null, so charts over unrelated domains never paint a cursor for each other —
117
+ * which is what makes "shared by default" safe. A categorical (unparseable) domain falls back to
118
+ * exact-match-only in BOTH resolutions, since "nearest"/"leading" over a category is meaningless.
70
119
  */
71
- export function resolveCursorPoint<T>(index: DomainIndex<T>, key: string): T | null {
120
+ export function resolveCursorPoint<T>(
121
+ index: DomainIndex<T>,
122
+ key: string,
123
+ resolution: CursorResolution = index.resolution,
124
+ ): T | null {
72
125
  const exact = index.byKey.get(key)
73
126
  if (exact !== undefined) return exact
74
127
 
@@ -77,6 +130,17 @@ export function resolveCursorPoint<T>(index: DomainIndex<T>, key: string): T | n
77
130
 
78
131
  const first = index.values[0] as number
79
132
  const last = index.values[index.values.length - 1] as number
133
+
134
+ if (resolution === 'leading') {
135
+ // Containment, NOT tolerance. A bucket keyed `k` covers `[k, nextKey)`, so a target before the
136
+ // first key sits in no bucket at all, and `last + step` is the first instant past the final
137
+ // one. Borrowing `'nearest'`'s symmetric ±step window here would snap a pre-domain target onto
138
+ // the first bucket and a post-domain one onto the last — painting a crosshair on a bucket that
139
+ // provably does not contain the hovered key, which is the exact lie this mode exists to remove.
140
+ if (target < first || target >= last + index.step) return null
141
+ return resolveLeading(index, target)
142
+ }
143
+
80
144
  if (target < first - index.step || target > last + index.step) return null
81
145
 
82
146
  let lo = 0
@@ -1,6 +1,7 @@
1
1
  import { localPoint } from '@visx/event'
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
3
  import type { KeyboardEvent, PointerEvent } from 'react'
4
+ import type { CursorResolution } from '../cursor/resolve'
4
5
  import { buildDomainIndex, resolveCursorPoint } from '../cursor/resolve'
5
6
  import { useCursorState, useCursorStore } from '../cursor/scope'
6
7
 
@@ -38,6 +39,7 @@ export function useChartCursor<T>({
38
39
  getKey,
39
40
  xScale,
40
41
  marginLeft,
42
+ resolution = 'nearest',
41
43
  }: {
42
44
  data: readonly T[]
43
45
  chartId: string
@@ -45,6 +47,12 @@ export function useChartCursor<T>({
45
47
  /** Own x scale — maps a domain key to a plot-local x offset. */
46
48
  xScale: (key: string) => number | undefined
47
49
  marginLeft: number
50
+ /**
51
+ * How a sibling's broadcast key resolves against this chart's own points. Default `'nearest'`
52
+ * (point domains). Pass `'leading'` when `getKey` returns a bucket's leading edge (a weekly
53
+ * series keyed by its Monday, a monthly series keyed by its 1st) — see {@link CursorResolution}.
54
+ */
55
+ resolution?: CursorResolution
48
56
  }): ChartCursor<T> {
49
57
  const store = useCursorStore()
50
58
  const cursor = useCursorState()
@@ -58,7 +66,10 @@ export function useChartCursor<T>({
58
66
  const xScaleRef = useRef(xScale)
59
67
  xScaleRef.current = xScale
60
68
 
61
- const index = useMemo(() => buildDomainIndex(data, getKeyRef.current), [data])
69
+ const index = useMemo(
70
+ () => buildDomainIndex(data, getKeyRef.current, resolution),
71
+ [data, resolution],
72
+ )
62
73
 
63
74
  const frameRef = useRef<number | null>(null)
64
75
  const pendingRef = useRef<CursorAnchor | null>(null)
@@ -57,6 +57,7 @@ export {
57
57
  resolveAxisDomain,
58
58
  type CartesianChartProps,
59
59
  type CartesianTooltipConfig,
60
+ type CartesianTooltipRowContext,
60
61
  type AxisConfig,
61
62
  type PlotContext,
62
63
  } from './primitives/CartesianChart'
@@ -90,6 +91,7 @@ export { AreaGradient, areaFillUrl } from './primitives/AreaGradient'
90
91
 
91
92
  // ── Hooks ────────────────────────────────────────────────────────────────
92
93
  export { useChartCursor, type ChartCursor, type CursorAnchor } from './hooks/useChartCursor'
94
+ export type { CursorResolution } from './cursor/resolve'
93
95
  export { useChartSize, type UseChartSizeResult, type ChartSize } from './hooks/useChartSize'
94
96
 
95
97
  // ── Utils ────────────────────────────────────────────────────────────────
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `Bars` — the `tooltip: false` seam threaded from `SeriesStyle.tooltip` through `BarsBar`/
3
+ * `BarsLine` (replacing the removed per-kind `hideBarTooltipRows`), plus the `BarsBar<T>`
4
+ * generic-default assignability guarantee. Real DOM harness (`tests/setup/dom.ts`): the cursor
5
+ * only advances via `useChartCursor`'s keyboard path (`ArrowRight` on the overlay's `role="slider"`),
6
+ * which is deterministic regardless of happy-dom's zeroed layout geometry — unlike the pointer path,
7
+ * which needs a real `getBoundingClientRect`.
8
+ */
9
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
10
+ import { describe, expect, test } from 'bun:test'
11
+ import { ChartCursorScope } from '../cursor/scope'
12
+ import { CartesianChart } from '../primitives/CartesianChart'
13
+ import type { ChartSeries } from '../series'
14
+ import { Bars } from './Bars'
15
+ import type { BarsBar } from './Bars'
16
+
17
+ type Row = { date: string; shown: number; quiet: number; trend: number }
18
+
19
+ const rows: Row[] = [{ date: '2026-08-01', shown: 10, quiet: 5, trend: 7 }]
20
+
21
+ describe('Bars — tooltip: false drops only that row', () => {
22
+ test('a quiet bar is drawn and legended but produces no tooltip row', async () => {
23
+ render(
24
+ <Bars<Row>
25
+ data={rows}
26
+ chartId="bars-tooltip"
27
+ getX={(d) => d.date}
28
+ getValue={(d, key) => d[key as 'shown' | 'quiet']}
29
+ positiveBars={[
30
+ { key: 'shown', label: 'Shown', color: '#111' },
31
+ { key: 'quiet', label: 'Quiet', color: '#222', tooltip: false },
32
+ ]}
33
+ />,
34
+ )
35
+
36
+ // Both series are legended regardless of the tooltip flag.
37
+ expect(screen.getByText('Shown')).toBeTruthy()
38
+ expect(screen.getByText('Quiet')).toBeTruthy()
39
+
40
+ const slider = screen.getByRole('slider')
41
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
42
+
43
+ // The tooltip row duplicates 'Shown' (legend + row); 'Quiet' stays legend-only. The tooltip
44
+ // anchor is set through a `requestAnimationFrame` coalesce, so wait for the SECOND 'Shown' to
45
+ // land rather than resolving on the legend's already-present first one.
46
+ await waitFor(() => expect(screen.getAllByText('Shown')).toHaveLength(2))
47
+ expect(screen.getAllByText('Quiet')).toHaveLength(1)
48
+ })
49
+ })
50
+
51
+ describe('Bars — lines[].tooltip: false drops only that row', () => {
52
+ test('a quiet line is drawn and legended but produces no tooltip row', async () => {
53
+ render(
54
+ <Bars<Row>
55
+ data={rows}
56
+ chartId="bars-line-tooltip"
57
+ getX={(d) => d.date}
58
+ getValue={(d, key) => d[key as 'shown' | 'quiet' | 'trend']}
59
+ positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
60
+ lines={[{ key: 'trend', label: 'Trend', color: '#333', tooltip: false }]}
61
+ />,
62
+ )
63
+
64
+ // Both series are legended regardless of the tooltip flag.
65
+ expect(screen.getByText('Shown')).toBeTruthy()
66
+ expect(screen.getByText('Trend')).toBeTruthy()
67
+
68
+ const slider = screen.getByRole('slider')
69
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
70
+
71
+ // The tooltip row duplicates 'Shown' (legend + row); 'Trend' stays legend-only.
72
+ await waitFor(() => expect(screen.getAllByText('Shown')).toHaveLength(2))
73
+ expect(screen.getAllByText('Trend')).toHaveLength(1)
74
+ })
75
+ })
76
+
77
+ describe('Bars — lines[].strokeOpacity dims the plotted stroke', () => {
78
+ test('the line path carries the configured stroke-opacity attribute', () => {
79
+ const { container } = render(
80
+ <Bars<Row>
81
+ data={rows}
82
+ chartId="bars-line-opacity"
83
+ getX={(d) => d.date}
84
+ getValue={(d, key) => d[key as 'shown' | 'quiet' | 'trend']}
85
+ positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
86
+ lines={[{ key: 'trend', label: 'Trend', color: '#333', strokeOpacity: 0.4 }]}
87
+ />,
88
+ )
89
+
90
+ const path = container.querySelector('path[stroke="#333"]')
91
+ expect(path?.getAttribute('stroke-opacity')).toBe('0.4')
92
+ })
93
+ })
94
+
95
+ describe('Bars — formatX', () => {
96
+ test('a custom formatX renders on the bottom axis instead of the default DD.MM', () => {
97
+ const { container } = render(
98
+ <Bars<Row>
99
+ data={rows}
100
+ chartId="bars-formatx"
101
+ getX={(d) => d.date}
102
+ getValue={(d, key) => d[key as 'shown' | 'quiet' | 'trend']}
103
+ positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
104
+ formatX={(key) => `X:${key}`}
105
+ />,
106
+ )
107
+ expect(container.innerHTML).toContain('X:2026-08-01')
108
+ })
109
+ })
110
+
111
+ describe('Bars — cursorResolution threads through to sibling resolution', () => {
112
+ // Daily calendar Aug 01–14 (a plain CartesianChart driver) alongside a Bars sibling folded into
113
+ // 2 weekly buckets keyed by each week's leading day (Aug 01, Aug 08) — the exact shape of the
114
+ // playground's "Weekly digest" pairing. Proves the prop reaches THIS kind, not just
115
+ // `CartesianChart` (a kind dropping the forward would silently fall back to 'nearest').
116
+ const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
117
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
118
+ shown: i,
119
+ quiet: i,
120
+ trend: i,
121
+ }))
122
+ const foldedRows: Row[] = [
123
+ { date: '2026-08-01', shown: 0, quiet: 0, trend: 0 },
124
+ { date: '2026-08-08', shown: 1, quiet: 1, trend: 1 },
125
+ ]
126
+ const dailySeries: ChartSeries<Row>[] = [
127
+ { key: 'shown', label: 'Shown', color: '#111', mark: 'line', getValue: (d) => d.shown },
128
+ ]
129
+
130
+ function renderPair(cursorResolution?: 'leading') {
131
+ render(
132
+ <ChartCursorScope>
133
+ <CartesianChart<Row>
134
+ data={dailyRows}
135
+ chartId="bars-daily"
136
+ getX={(d) => d.date}
137
+ series={dailySeries}
138
+ ariaLabel="Daily"
139
+ >
140
+ {() => null}
141
+ </CartesianChart>
142
+ <Bars<Row>
143
+ data={foldedRows}
144
+ chartId="bars-folded"
145
+ getX={(d) => d.date}
146
+ getValue={(d, key) => d[key as 'shown']}
147
+ positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
148
+ ariaLabel="Folded"
149
+ {...(cursorResolution !== undefined && { cursorResolution })}
150
+ />
151
+ </ChartCursorScope>,
152
+ )
153
+ }
154
+
155
+ // Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
156
+ // via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
157
+ function driveDailyToAug05() {
158
+ const dailySlider = screen.getByRole('slider', { name: 'Daily' })
159
+ for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
160
+ }
161
+
162
+ test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
163
+ renderPair('leading')
164
+ driveDailyToAug05()
165
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
166
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
167
+ })
168
+
169
+ test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
170
+ renderPair()
171
+ driveDailyToAug05()
172
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
173
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
174
+ })
175
+ })
176
+
177
+ describe('BarsBar<T> — the `= unknown` default stays assignable', () => {
178
+ test('an existing untyped BarsBar[] array is assignable to BarsBar<Row>[] contravariantly', () => {
179
+ const bars: BarsBar[] = [{ key: 'shown', label: 'Shown', color: '#111' }]
180
+ const typed: BarsBar<Row>[] = bars
181
+ expect(typed).toBe(bars)
182
+ })
183
+ })
@@ -1,6 +1,7 @@
1
1
  import { curveMonotoneX } from '@visx/curve'
2
2
  import { LinePath } from '@visx/shape'
3
3
  import { memo, useMemo } from 'react'
4
+ import type { CursorResolution } from '../cursor/resolve'
4
5
  import { VX } from '../../tokens'
5
6
  import type { ChartMargin } from '../../tokens'
6
7
  import { CartesianChart } from '../primitives/CartesianChart'
@@ -10,7 +11,12 @@ import { LINE_OVERLAY_STROKE_WIDTH } from '../series'
10
11
  import type { ChartLegendConfig, ChartSeries } from '../series'
11
12
  import { padAutoLower } from '../utils/domain'
12
13
 
13
- export type BarsBar = {
14
+ /**
15
+ * `T = unknown` is load-bearing, not a shortcut: it keeps a consumer's existing
16
+ * `const bars: BarsBar[] = [...]` compiling (and still assignable to `BarsBar<Row>[]`,
17
+ * contravariantly) even though the type is now generic over the datum.
18
+ */
19
+ export type BarsBar<T = unknown> = {
14
20
  /** Field key — `getValue(d, key)` extracts the number (null = skip this slot, not domain hole). */
15
21
  key: string
16
22
  /** Tooltip + legend label. */
@@ -18,22 +24,30 @@ export type BarsBar = {
18
24
  /** Fill color (a resolved CSS color / token ref). Per `docs/DESIGN-SPEC.md` §5, the primary bar
19
25
  * series should typically resolve to `VX.accent`, a secondary companion to `VX.faint`. */
20
26
  color: string
21
- /** Per-series tooltip value formatter — overrides the left/right axis's own format. */
22
- formatValue?: (v: number) => string
27
+ /** Per-series tooltip value formatter — overrides the left/right axis's own format. Receives the
28
+ * hovered datum alongside the value. */
29
+ formatValue?: (v: number, d: T) => string
23
30
  /** Y axis to plot against. Honored only in grouped layout. Default 'left'. */
24
31
  axisSide?: 'left' | 'right'
25
32
  /** Relative width within the group (only honored in grouped layout). Default 1 — all bars equal. */
26
33
  weight?: number
34
+ /** Default true; false = drawn and legended, but never listed as a tooltip row — threads
35
+ * `SeriesStyle.tooltip` through `Bars`, replacing the removed per-kind `hideBarTooltipRows`. */
36
+ tooltip?: boolean
27
37
  }
28
38
 
29
- export type BarsLine = {
39
+ export type BarsLine<T = unknown> = {
30
40
  key: string
31
41
  label: string
32
42
  color: string
33
43
  axisSide?: 'left' | 'right'
34
44
  strokeWidth?: number
35
45
  dashed?: boolean
36
- formatValue?: (v: number) => string
46
+ formatValue?: (v: number, d: T) => string
47
+ /** Default true; false = drawn and legended, but never listed as a tooltip row. */
48
+ tooltip?: boolean
49
+ /** Dims the plotted stroke AND the legend swatch, never the tooltip row (`SeriesStyle.strokeOpacity`). */
50
+ strokeOpacity?: number
37
51
  }
38
52
 
39
53
  /** @deprecated Use ZoneSpec from primitives/ZoneRects. Kept as an alias. */
@@ -57,12 +71,12 @@ export type BarsProps<T> = {
57
71
  getValue: (d: T, key: string) => number | null
58
72
 
59
73
  /** 1+ bar series, stacked when ≥2, plotted above baseline (y >= 0). */
60
- positiveBars: BarsBar[]
74
+ positiveBars: BarsBar<T>[]
61
75
  /** Optional bar series stacked below baseline (rendered as flipped negatives). */
62
- negativeBars?: BarsBar[]
76
+ negativeBars?: BarsBar<T>[]
63
77
 
64
78
  /** 0–2 line overlays, each on left or right axis. */
65
- lines?: BarsLine[]
79
+ lines?: BarsLine<T>[]
66
80
 
67
81
  /** Horizontal value-range overlays (target zones, optimal bands). */
68
82
  zones?: BarsZone[]
@@ -95,6 +109,14 @@ export type BarsProps<T> = {
95
109
 
96
110
  /** X tick count override. Default: as many as fit. */
97
111
  xTicks?: number
112
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
113
+ formatX?: (key: string) => string
114
+ /**
115
+ * How a sibling chart's broadcast cursor key resolves against this chart's points. Default
116
+ * `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
117
+ * keyed by its Monday) — see `CursorResolution`.
118
+ */
119
+ cursorResolution?: CursorResolution
98
120
 
99
121
  /** `false` disables the tooltip entirely (and with it the crosshair dots). */
100
122
  tooltip?: CartesianTooltipConfig<T> | false
@@ -139,6 +161,8 @@ function BarsInner<T>(props: BarsProps<T>) {
139
161
  barLayout = 'stacked',
140
162
  barOpacity,
141
163
  xTicks,
164
+ formatX,
165
+ cursorResolution,
142
166
  tooltip,
143
167
  margin,
144
168
  legend,
@@ -157,6 +181,7 @@ function BarsInner<T>(props: BarsProps<T>) {
157
181
  ...(b.axisSide !== undefined && { axis: b.axisSide }),
158
182
  getValue: (d: T) => getValue(d, b.key),
159
183
  ...(b.formatValue !== undefined && { formatValue: b.formatValue }),
184
+ ...(b.tooltip !== undefined && { tooltip: b.tooltip }),
160
185
  })),
161
186
  [positiveBars, negativeBars, getValue],
162
187
  )
@@ -173,6 +198,8 @@ function BarsInner<T>(props: BarsProps<T>) {
173
198
  ...(ln.axisSide !== undefined && { axis: ln.axisSide }),
174
199
  getValue: (d: T) => getValue(d, ln.key),
175
200
  ...(ln.formatValue !== undefined && { formatValue: ln.formatValue }),
201
+ ...(ln.tooltip !== undefined && { tooltip: ln.tooltip }),
202
+ ...(ln.strokeOpacity !== undefined && { strokeOpacity: ln.strokeOpacity }),
176
203
  })),
177
204
  [lines, getValue],
178
205
  )
@@ -253,6 +280,8 @@ function BarsInner<T>(props: BarsProps<T>) {
253
280
  y={yConfig}
254
281
  {...(y2 !== undefined && { y2 })}
255
282
  {...(xTicks !== undefined && { xTicks })}
283
+ {...(formatX !== undefined && { formatX })}
284
+ {...(cursorResolution !== undefined && { cursorResolution })}
256
285
  {...(zones !== undefined && { zones })}
257
286
  {...(mappedRefLines !== undefined && { refLines: mappedRefLines })}
258
287
  {...(height !== undefined && { height })}
@@ -294,9 +323,9 @@ type LinePt<T> = { __d: T; __y: number }
294
323
  type BarsMarksProps<T> = {
295
324
  getX: (d: T) => string
296
325
  getValue: (d: T, key: string) => number | null
297
- positiveBars: BarsBar[]
298
- negativeBars: BarsBar[]
299
- lines: BarsLine[]
326
+ positiveBars: BarsBar<T>[]
327
+ negativeBars: BarsBar<T>[]
328
+ lines: BarsLine<T>[]
300
329
  barWidthRatio: number
301
330
  barLayout: 'stacked' | 'grouped'
302
331
  barOpacity?: (d: T, key: string) => number
@@ -485,7 +514,7 @@ function BarsMarks<T>({
485
514
  stroke={ln.color}
486
515
  strokeWidth={ln.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
487
516
  strokeDasharray={ln.dashed ? VX.dashArray : undefined}
488
- strokeOpacity={dim(ln.key)}
517
+ strokeOpacity={dim(ln.key) * (ln.strokeOpacity ?? 1)}
489
518
  curve={curveMonotoneX}
490
519
  />
491
520
  )