basalt-ui 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/agent/rules/basalt-charts.md +28 -9
  3. package/agent/templates/CLAUDE-block.md.tpl +9 -6
  4. package/dist/charts/index.d.ts +1 -1
  5. package/dist/charts/index.d.ts.map +1 -1
  6. package/dist/charts/kinds/Bars.d.ts +21 -8
  7. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  8. package/dist/charts/kinds/Bars.js +6 -3
  9. package/dist/charts/kinds/Bars.test.d.ts +2 -0
  10. package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
  11. package/dist/charts/kinds/DualPanel.d.ts +13 -0
  12. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  13. package/dist/charts/kinds/DualPanel.js +55 -8
  14. package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
  15. package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
  16. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  17. package/dist/charts/kinds/MultiLine.js +35 -25
  18. package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
  19. package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
  20. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  21. package/dist/charts/kinds/ZonedLine.js +1 -0
  22. package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
  23. package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
  24. package/dist/charts/primitives/CartesianChart.d.ts +22 -4
  25. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  26. package/dist/charts/primitives/CartesianChart.js +18 -8
  27. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -1
  28. package/dist/charts/primitives/ChartLegend.d.ts +3 -0
  29. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  30. package/dist/charts/primitives/ChartLegend.js +4 -0
  31. package/dist/charts/primitives/XZoneRects.d.ts +22 -8
  32. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -1
  33. package/dist/charts/primitives/XZoneRects.js +39 -2
  34. package/dist/charts/series.d.ts +12 -2
  35. package/dist/charts/series.d.ts.map +1 -1
  36. package/dist/charts/series.js +2 -1
  37. package/llms.txt +2 -2
  38. package/package.json +1 -1
  39. package/src/charts/index.ts +1 -0
  40. package/src/charts/kinds/Bars.test.tsx +98 -0
  41. package/src/charts/kinds/Bars.tsx +28 -12
  42. package/src/charts/kinds/DualPanel.test.tsx +131 -0
  43. package/src/charts/kinds/DualPanel.tsx +81 -9
  44. package/src/charts/kinds/MultiLine.test.tsx +37 -0
  45. package/src/charts/kinds/MultiLine.tsx +45 -28
  46. package/src/charts/kinds/ZonedLine.test.tsx +37 -0
  47. package/src/charts/kinds/ZonedLine.tsx +1 -0
  48. package/src/charts/primitives/{CartesianChart.test.ts → CartesianChart.test.tsx} +62 -1
  49. package/src/charts/primitives/CartesianChart.tsx +44 -12
  50. package/src/charts/primitives/ChartLegend.test.tsx +30 -0
  51. package/src/charts/primitives/ChartLegend.tsx +7 -0
  52. package/src/charts/primitives/XZoneRects.test.tsx +60 -0
  53. package/src/charts/primitives/XZoneRects.tsx +75 -10
  54. package/src/charts/series.test.ts +51 -0
  55. package/src/charts/series.ts +15 -4
@@ -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
  })
@@ -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
- formatValue?: (v: number) => string
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 }),