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.
- package/CHANGELOG.md +14 -0
- package/agent/rules/basalt-charts.md +74 -13
- package/agent/templates/CLAUDE-block.md.tpl +9 -6
- package/dist/charts/cursor/resolve.d.ts +29 -5
- package/dist/charts/cursor/resolve.d.ts.map +1 -1
- package/dist/charts/cursor/resolve.js +25 -4
- package/dist/charts/hooks/useChartCursor.d.ts +8 -1
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +6 -2
- package/dist/charts/index.d.ts +2 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +30 -8
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +10 -3
- package/dist/charts/kinds/Bars.test.d.ts +2 -0
- package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
- package/dist/charts/kinds/DualPanel.d.ts +30 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +82 -12
- package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
- package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
- package/dist/charts/kinds/MultiLine.d.ts +9 -0
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +45 -33
- package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
- package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
- package/dist/charts/kinds/StackedArea.d.ts +9 -0
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +16 -1
- package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
- package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
- package/dist/charts/kinds/ZonedLine.d.ts +9 -0
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +5 -0
- package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
- package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
- package/dist/charts/primitives/CartesianChart.d.ts +45 -6
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +29 -12
- package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.d.ts +8 -1
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +1 -1
- package/dist/charts/primitives/ChartLegend.d.ts +3 -0
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +4 -0
- package/dist/charts/primitives/ChartTooltip.d.ts +6 -1
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +3 -2
- package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
- package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
- package/dist/charts/primitives/XZoneRects.d.ts +22 -8
- package/dist/charts/primitives/XZoneRects.d.ts.map +1 -1
- package/dist/charts/primitives/XZoneRects.js +39 -2
- package/dist/charts/series.d.ts +16 -3
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/series.js +2 -1
- package/dist/charts/utils/domain.d.ts +15 -0
- package/dist/charts/utils/domain.d.ts.map +1 -1
- package/dist/charts/utils/domain.js +6 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/cursor/resolve.test.ts +62 -0
- package/src/charts/cursor/resolve.ts +71 -7
- package/src/charts/hooks/useChartCursor.ts +12 -1
- package/src/charts/index.ts +2 -0
- package/src/charts/kinds/Bars.test.tsx +183 -0
- package/src/charts/kinds/Bars.tsx +41 -12
- package/src/charts/kinds/DualPanel.test.tsx +441 -0
- package/src/charts/kinds/DualPanel.tsx +127 -11
- package/src/charts/kinds/MultiLine.test.tsx +218 -0
- package/src/charts/kinds/MultiLine.tsx +64 -36
- package/src/charts/kinds/StackedArea.test.tsx +114 -0
- package/src/charts/kinds/StackedArea.tsx +25 -1
- package/src/charts/kinds/ZonedLine.test.tsx +147 -0
- package/src/charts/kinds/ZonedLine.tsx +14 -0
- package/src/charts/primitives/CartesianChart.test.tsx +269 -0
- package/src/charts/primitives/CartesianChart.tsx +79 -19
- package/src/charts/primitives/ChartFrame.test.tsx +42 -0
- package/src/charts/primitives/ChartFrame.tsx +9 -2
- package/src/charts/primitives/ChartLegend.test.tsx +30 -0
- package/src/charts/primitives/ChartLegend.tsx +7 -0
- package/src/charts/primitives/ChartTooltip.test.tsx +23 -0
- package/src/charts/primitives/ChartTooltip.tsx +7 -1
- package/src/charts/primitives/XZoneRects.test.tsx +60 -0
- package/src/charts/primitives/XZoneRects.tsx +75 -10
- package/src/charts/series.test.ts +51 -0
- package/src/charts/series.ts +18 -6
- package/src/charts/utils/domain.test.ts +31 -1
- package/src/charts/utils/domain.ts +19 -0
- 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>(
|
|
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)
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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>(
|
|
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(
|
|
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)
|
package/src/charts/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
)
|