basalt-ui 1.15.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/agent/rules/basalt-charts.md +28 -9
- package/agent/templates/CLAUDE-block.md.tpl +9 -6
- package/dist/charts/index.d.ts +1 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +21 -8
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +6 -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 +13 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +55 -8
- 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.map +1 -1
- package/dist/charts/kinds/MultiLine.js +35 -25
- 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/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +1 -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 +22 -4
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +18 -8
- package/dist/charts/primitives/CartesianChart.test.d.ts.map +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/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 +12 -2
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/series.js +2 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/index.ts +1 -0
- package/src/charts/kinds/Bars.test.tsx +98 -0
- package/src/charts/kinds/Bars.tsx +28 -12
- package/src/charts/kinds/DualPanel.test.tsx +131 -0
- package/src/charts/kinds/DualPanel.tsx +81 -9
- package/src/charts/kinds/MultiLine.test.tsx +37 -0
- package/src/charts/kinds/MultiLine.tsx +45 -28
- package/src/charts/kinds/ZonedLine.test.tsx +37 -0
- package/src/charts/kinds/ZonedLine.tsx +1 -0
- package/src/charts/primitives/{CartesianChart.test.ts → CartesianChart.test.tsx} +62 -1
- package/src/charts/primitives/CartesianChart.tsx +44 -12
- package/src/charts/primitives/ChartLegend.test.tsx +30 -0
- package/src/charts/primitives/ChartLegend.tsx +7 -0
- 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 +15 -4
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
29
|
-
* chart for a typo'd or stale key, which is the exact class of
|
|
30
|
-
* draw is two different states" doctrine rejects: an unknown key
|
|
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
|
|
51
|
-
const
|
|
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
|
})
|
package/src/charts/series.ts
CHANGED
|
@@ -60,6 +60,14 @@ export type SeriesStyle = {
|
|
|
60
60
|
strokeWidth?: number
|
|
61
61
|
/** bar/area: the swatch honors this so it cannot lie. */
|
|
62
62
|
fillOpacity?: number
|
|
63
|
+
/**
|
|
64
|
+
* A MARK property — dims the plotted stroke, e.g. a faint moving-average companion line. The
|
|
65
|
+
* legend swatch honors it too (parity with `fillOpacity`, which it already honors — a swatch
|
|
66
|
+
* that lies is the thing the derived-legend rule exists to prevent). The TOOLTIP ROW swatch and
|
|
67
|
+
* the CROSSHAIR DOT deliberately do NOT honor it: those are 12px value-readout chips where a
|
|
68
|
+
* sub-1 opacity reads as a rendering bug, not as data.
|
|
69
|
+
*/
|
|
70
|
+
strokeOpacity?: number
|
|
63
71
|
/** Which y-axis this series is measured against. Default 'left'. `'right'` is what makes a
|
|
64
72
|
* chart dual-axis — `CartesianChart` reads it for the crosshair dots and the tooltip. */
|
|
65
73
|
axis?: 'left' | 'right'
|
|
@@ -84,8 +92,10 @@ export type SeriesStyle = {
|
|
|
84
92
|
export type ChartSeries<T> = SeriesStyle & {
|
|
85
93
|
/** null = line gap + skipped tooltip row. */
|
|
86
94
|
getValue: (d: T) => number | null
|
|
87
|
-
/** Per-series override of the shared tooltip/value formatter.
|
|
88
|
-
|
|
95
|
+
/** Per-series override of the shared tooltip/value formatter. Receives the hovered datum
|
|
96
|
+
* alongside the value so a row can read fields beyond the plotted number (e.g. `97.5 kg (92.5 ×
|
|
97
|
+
* 3)`). */
|
|
98
|
+
formatValue?: (v: number, d: T) => string
|
|
89
99
|
/** PR star / status dot. Return null for no marker at that point. */
|
|
90
100
|
getMarker?: (d: T) => { color?: string; r?: number } | null
|
|
91
101
|
}
|
|
@@ -112,6 +122,7 @@ function legendEntryFor(s: SeriesStyle): LegendEntry {
|
|
|
112
122
|
dashed: s.dash === 'dashed',
|
|
113
123
|
...(s.strokeWidth !== undefined && { strokeWidth: s.strokeWidth }),
|
|
114
124
|
...(s.fillOpacity !== undefined && { fillOpacity: s.fillOpacity }),
|
|
125
|
+
...(s.strokeOpacity !== undefined && { strokeOpacity: s.strokeOpacity }),
|
|
115
126
|
...(s.role !== undefined && { role: s.role }),
|
|
116
127
|
...(s.note !== undefined && { note: s.note }),
|
|
117
128
|
}
|
|
@@ -165,12 +176,12 @@ export function deriveTooltipRows<T>(
|
|
|
165
176
|
if (s.tooltip === false) continue
|
|
166
177
|
const value = s.getValue(datum)
|
|
167
178
|
if (value === null) continue
|
|
168
|
-
const format = s.formatValue ?? fallbackFormat
|
|
179
|
+
const format: (v: number, d: T) => string = s.formatValue ?? fallbackFormat
|
|
169
180
|
rows.push({
|
|
170
181
|
key: s.key,
|
|
171
182
|
label: s.label,
|
|
172
183
|
color: s.color,
|
|
173
|
-
value: format(value),
|
|
184
|
+
value: format(value, datum),
|
|
174
185
|
shape: shapeFor(s.mark),
|
|
175
186
|
dashed: s.dash === 'dashed',
|
|
176
187
|
...(s.strokeWidth !== undefined && { strokeWidth: s.strokeWidth }),
|