basalt-ui 1.14.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 (176) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +38 -2
  4. package/agent/rules/basalt-charts.md +279 -108
  5. package/agent/rules/basalt-mantine.md +4 -2
  6. package/agent/skills/basalt-charts/SKILL.md +114 -64
  7. package/agent/skills/basalt-design/SKILL.md +4 -3
  8. package/agent/templates/CLAUDE-block.md.tpl +9 -6
  9. package/configs/oxlint-plugin.js +152 -0
  10. package/configs/oxlint-plugin.test.ts +107 -0
  11. package/configs/oxlint.json +2 -0
  12. package/dist/charts/cursor/resolve.d.ts +33 -0
  13. package/dist/charts/cursor/resolve.d.ts.map +1 -0
  14. package/dist/charts/cursor/resolve.js +61 -0
  15. package/dist/charts/cursor/resolve.test.d.ts +2 -0
  16. package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
  17. package/dist/charts/cursor/scope.d.ts +17 -0
  18. package/dist/charts/cursor/scope.d.ts.map +1 -0
  19. package/dist/charts/cursor/scope.js +20 -0
  20. package/dist/charts/cursor/store.d.ts +31 -0
  21. package/dist/charts/cursor/store.d.ts.map +1 -0
  22. package/dist/charts/cursor/store.js +25 -0
  23. package/dist/charts/cursor/store.test.d.ts +2 -0
  24. package/dist/charts/cursor/store.test.d.ts.map +1 -0
  25. package/dist/charts/hooks/useChartCursor.d.ts +39 -0
  26. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
  27. package/dist/charts/hooks/useChartCursor.js +104 -0
  28. package/dist/charts/hooks/useChartSize.d.ts +8 -4
  29. package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
  30. package/dist/charts/index.d.ts +16 -13
  31. package/dist/charts/index.d.ts.map +1 -1
  32. package/dist/charts/index.js +38 -19
  33. package/dist/charts/kinds/Bars.d.ts +45 -54
  34. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  35. package/dist/charts/kinds/Bars.js +235 -392
  36. package/dist/charts/kinds/Bars.test.d.ts +2 -0
  37. package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
  38. package/dist/charts/kinds/Donut.d.ts +5 -4
  39. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  40. package/dist/charts/kinds/Donut.js +17 -14
  41. package/dist/charts/kinds/DualPanel.d.ts +13 -0
  42. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  43. package/dist/charts/kinds/DualPanel.js +185 -92
  44. package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
  45. package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
  46. package/dist/charts/kinds/Heatmap.d.ts +15 -11
  47. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  48. package/dist/charts/kinds/Heatmap.js +38 -22
  49. package/dist/charts/kinds/MultiLine.d.ts +27 -35
  50. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  51. package/dist/charts/kinds/MultiLine.js +122 -235
  52. package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
  53. package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
  54. package/dist/charts/kinds/StackedArea.d.ts +21 -10
  55. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  56. package/dist/charts/kinds/StackedArea.js +74 -132
  57. package/dist/charts/kinds/ZonedLine.d.ts +24 -40
  58. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  59. package/dist/charts/kinds/ZonedLine.js +104 -184
  60. package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
  61. package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
  62. package/dist/charts/layout/auto-margin.d.ts +60 -0
  63. package/dist/charts/layout/auto-margin.d.ts.map +1 -0
  64. package/dist/charts/layout/auto-margin.js +37 -0
  65. package/dist/charts/layout/auto-margin.test.d.ts +2 -0
  66. package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
  67. package/dist/charts/primitives/CartesianChart.d.ts +161 -0
  68. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
  69. package/dist/charts/primitives/CartesianChart.js +359 -0
  70. package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
  71. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
  72. package/dist/charts/primitives/ChartFrame.d.ts +16 -5
  73. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  74. package/dist/charts/primitives/ChartFrame.js +17 -2
  75. package/dist/charts/primitives/ChartLegend.d.ts +15 -1
  76. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  77. package/dist/charts/primitives/ChartLegend.js +25 -3
  78. package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
  79. package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
  80. package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
  81. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  82. package/dist/charts/primitives/ChartTooltip.js +45 -19
  83. package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
  84. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  85. package/dist/charts/primitives/HoverOverlay.js +22 -1
  86. package/dist/charts/primitives/XZoneRects.d.ts +54 -0
  87. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
  88. package/dist/charts/primitives/XZoneRects.js +65 -0
  89. package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
  90. package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
  91. package/dist/charts/series.d.ts +28 -3
  92. package/dist/charts/series.d.ts.map +1 -1
  93. package/dist/charts/series.js +5 -2
  94. package/dist/charts/series.test.d.ts +2 -0
  95. package/dist/charts/series.test.d.ts.map +1 -0
  96. package/dist/charts/utils/measure-text.d.ts +25 -0
  97. package/dist/charts/utils/measure-text.d.ts.map +1 -0
  98. package/dist/charts/utils/measure-text.js +64 -0
  99. package/dist/charts/utils/measure-text.test.d.ts +2 -0
  100. package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
  101. package/dist/content/fence-block.d.ts.map +1 -1
  102. package/dist/surfaces.d.ts +1 -1
  103. package/dist/surfaces.d.ts.map +1 -1
  104. package/dist/surfaces.js +1 -1
  105. package/dist/tokens/chart-margin.test.d.ts +2 -0
  106. package/dist/tokens/chart-margin.test.d.ts.map +1 -0
  107. package/dist/tokens/index.d.ts +29 -0
  108. package/dist/tokens/index.d.ts.map +1 -1
  109. package/dist/tokens/index.js +10 -0
  110. package/dist/tokens/palette.d.ts +4 -0
  111. package/dist/tokens/palette.d.ts.map +1 -1
  112. package/dist/tokens/palette.js +3 -0
  113. package/llms.txt +3 -3
  114. package/package.json +1 -1
  115. package/src/charts/cursor/resolve.test.ts +61 -0
  116. package/src/charts/cursor/resolve.ts +102 -0
  117. package/src/charts/cursor/scope.tsx +32 -0
  118. package/src/charts/cursor/store.test.ts +46 -0
  119. package/src/charts/cursor/store.ts +53 -0
  120. package/src/charts/hooks/useChartCursor.ts +163 -0
  121. package/src/charts/hooks/useChartSize.ts +8 -4
  122. package/src/charts/index.ts +35 -14
  123. package/src/charts/kinds/Bars.test.tsx +98 -0
  124. package/src/charts/kinds/Bars.tsx +350 -467
  125. package/src/charts/kinds/Donut.tsx +31 -23
  126. package/src/charts/kinds/DualPanel.test.tsx +131 -0
  127. package/src/charts/kinds/DualPanel.tsx +301 -135
  128. package/src/charts/kinds/Heatmap.tsx +72 -47
  129. package/src/charts/kinds/MultiLine.test.tsx +37 -0
  130. package/src/charts/kinds/MultiLine.tsx +171 -290
  131. package/src/charts/kinds/StackedArea.tsx +116 -175
  132. package/src/charts/kinds/ZonedLine.test.tsx +37 -0
  133. package/src/charts/kinds/ZonedLine.tsx +149 -255
  134. package/src/charts/layout/auto-margin.test.ts +42 -0
  135. package/src/charts/layout/auto-margin.ts +110 -0
  136. package/src/charts/primitives/CartesianChart.test.tsx +160 -0
  137. package/src/charts/primitives/CartesianChart.tsx +598 -0
  138. package/src/charts/primitives/ChartFrame.test.tsx +33 -0
  139. package/src/charts/primitives/ChartFrame.tsx +40 -4
  140. package/src/charts/primitives/ChartLegend.test.tsx +78 -0
  141. package/src/charts/primitives/ChartLegend.tsx +52 -2
  142. package/src/charts/primitives/ChartTooltip.tsx +67 -37
  143. package/src/charts/primitives/HoverOverlay.tsx +39 -3
  144. package/src/charts/primitives/XZoneRects.test.tsx +123 -0
  145. package/src/charts/primitives/XZoneRects.tsx +132 -0
  146. package/src/charts/series.test.ts +112 -0
  147. package/src/charts/series.ts +33 -5
  148. package/src/charts/utils/measure-text.test.ts +36 -0
  149. package/src/charts/utils/measure-text.ts +109 -0
  150. package/src/content/fence-block.tsx +2 -3
  151. package/src/surfaces.ts +2 -1
  152. package/src/theme/spacing.test.ts +2 -0
  153. package/src/tokens/chart-margin.test.ts +39 -0
  154. package/src/tokens/density.test.ts +1 -0
  155. package/src/tokens/index.ts +36 -0
  156. package/src/tokens/palette.ts +3 -0
  157. package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
  158. package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
  159. package/dist/charts/hooks/useChartTooltip.js +0 -26
  160. package/dist/charts/hooks/useHoverSync.d.ts +0 -46
  161. package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
  162. package/dist/charts/hooks/useHoverSync.js +0 -75
  163. package/dist/charts/hover-context.d.ts +0 -21
  164. package/dist/charts/hover-context.d.ts.map +0 -1
  165. package/dist/charts/hover-context.js +0 -12
  166. package/dist/charts/hover-sync.d.ts +0 -14
  167. package/dist/charts/hover-sync.d.ts.map +0 -1
  168. package/dist/charts/hover-sync.js +0 -17
  169. package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
  170. package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
  171. package/dist/charts/primitives/ResponsiveChart.js +0 -24
  172. package/src/charts/hooks/useChartTooltip.ts +0 -31
  173. package/src/charts/hooks/useHoverSync.ts +0 -152
  174. package/src/charts/hover-context.ts +0 -28
  175. package/src/charts/hover-sync.tsx +0 -37
  176. package/src/charts/primitives/ResponsiveChart.tsx +0 -64
@@ -0,0 +1,132 @@
1
+ import type { scalePoint } from '@visx/scale'
2
+
3
+ /**
4
+ * The point-scale instance type, derived from `@visx/scale`'s own `scalePoint` return type.
5
+ * Equivalent to d3's `ScalePoint<string>` but resolved through `@visx/scale` (an installed dep) so
6
+ * it needs no `@types/d3-scale` and stays inside the visx-only-in-charts boundary.
7
+ */
8
+ type PointScale = ReturnType<typeof scalePoint<string>>
9
+
10
+ export type XZoneSpec = {
11
+ /** Left edge — a domain key produced by the kind's `getX`. Omit for "start of the plot area". */
12
+ from?: string
13
+ /** Right edge — a domain key produced by the kind's `getX`. Omit for "end of the plot area". */
14
+ to?: string
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)
60
+ }
61
+
62
+ /**
63
+ * Vertical zone backgrounds (x-range overlays — twilight bands, a shaded time window) — one
64
+ * <rect> per zone, spanning y=[0, height] and x=[scale(from), scale(to)]. The x analog of
65
+ * `ZoneRects`, for a `scalePoint` x-axis instead of a `scaleLinear` y-axis.
66
+ *
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.
70
+ *
71
+ * A key that is not in the scale's domain skips that band entirely (renders nothing for it) —
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".
76
+ *
77
+ * A degenerate resolved range (right edge <= left edge, e.g. an inverted `from`/`to` pair) is
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.
81
+ */
82
+ export function XZoneRects({
83
+ zones,
84
+ height,
85
+ xScale,
86
+ }: {
87
+ zones: XZoneSpec[]
88
+ /** Plot-area height (yMax); rects span from 0 to this. */
89
+ height: number
90
+ xScale: PointScale
91
+ }) {
92
+ return (
93
+ <>
94
+ {zones.map((z, i) => {
95
+ const [rangeStart, rangeEnd] = xScale.range() as [number, number]
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
+ })
117
+ if (xFrom === undefined || xTo === undefined) return null
118
+ if (xTo <= xFrom) return null
119
+ return (
120
+ <rect
121
+ key={`xzone-${i}`}
122
+ x={xFrom}
123
+ y={0}
124
+ width={xTo - xFrom}
125
+ height={height}
126
+ fill={z.fill}
127
+ />
128
+ )
129
+ })}
130
+ </>
131
+ )
132
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { deriveLegend, deriveTooltipRows } from './series'
3
+ import type { SeriesStyle } from './series'
4
+
5
+ const baseSeries: SeriesStyle = {
6
+ key: 'low-cloud',
7
+ label: 'Low cloud',
8
+ color: '#000',
9
+ mark: 'line',
10
+ }
11
+
12
+ describe('deriveLegend note passthrough', () => {
13
+ test('carries note through when set', () => {
14
+ const [entry] = deriveLegend([{ ...baseSeries, note: '0% all night' }])
15
+ expect(entry?.note).toBe('0% all night')
16
+ })
17
+
18
+ test('omits the note key entirely when unset', () => {
19
+ const [entry] = deriveLegend([baseSeries])
20
+ expect(entry).not.toHaveProperty('note')
21
+ })
22
+ })
23
+
24
+ describe('deriveTooltipRows', () => {
25
+ type Row = { v: number }
26
+ const base = { color: '#000', mark: 'bar' as const, getValue: (d: Row) => d.v }
27
+ const fmt = (v: number) => String(v)
28
+
29
+ test('a `tooltip: false` series is drawn and legended but produces no row', () => {
30
+ const rows = deriveTooltipRows(
31
+ [
32
+ { ...base, key: 'shown', label: 'Shown' },
33
+ { ...base, key: 'quiet', label: 'Quiet', tooltip: false },
34
+ ],
35
+ { v: 1 },
36
+ fmt,
37
+ )
38
+ expect(rows.map((r) => r.key)).toEqual(['shown'])
39
+ expect(deriveLegend([{ ...base, key: 'quiet', label: 'Quiet', tooltip: false }])).toHaveLength(
40
+ 1,
41
+ )
42
+ })
43
+
44
+ test('a null value still skips its row', () => {
45
+ const rows = deriveTooltipRows(
46
+ [{ ...base, key: 'gap', label: 'Gap', getValue: () => null }],
47
+ { v: 1 },
48
+ fmt,
49
+ )
50
+ expect(rows).toHaveLength(0)
51
+ })
52
+
53
+ test('a per-series formatValue wins over the shared fallback', () => {
54
+ const rows = deriveTooltipRows(
55
+ [{ ...base, key: 'a', label: 'A', formatValue: (v) => `${v}%` }],
56
+ { v: 42 },
57
+ fmt,
58
+ )
59
+ expect(rows[0]?.value).toBe('42%')
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
+ })
112
+ })
@@ -43,6 +43,8 @@ export type ChartLegendConfig = {
43
43
  groups?: boolean
44
44
  /** Wrap cap → "+N more" rollup at high cardinality. */
45
45
  maxRows?: number
46
+ /** Clicking an entry hides that series. Default: on whenever there is more than one entry. */
47
+ toggle?: boolean
46
48
  }
47
49
 
48
50
  /** Visual identity of a series — everything the legend + tooltip swatch need. No accessors, no `T`. */
@@ -58,20 +60,42 @@ export type SeriesStyle = {
58
60
  strokeWidth?: number
59
61
  /** bar/area: the swatch honors this so it cannot lie. */
60
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
71
+ /** Which y-axis this series is measured against. Default 'left'. `'right'` is what makes a
72
+ * chart dual-axis — `CartesianChart` reads it for the crosshair dots and the tooltip. */
73
+ axis?: 'left' | 'right'
61
74
  /** Default 'series'. */
62
75
  role?: SeriesRole
63
76
  /** Default true; false = companion folded under `parent`. */
64
77
  legend?: boolean
78
+ /**
79
+ * Default true; false = drawn and legended, but NEVER listed as a tooltip row. The replacement
80
+ * for the per-kind `hideBarTooltipRows` escape the old layer had: it belongs to the series, not to the kind, so
81
+ * one series can opt out without the kind growing a prop for it.
82
+ */
83
+ tooltip?: boolean
65
84
  /** e.g. an MA line names its parent so hover-dimming keeps the pair. */
66
85
  parent?: string
86
+ /** Short qualifier rendered after the label in muted text — e.g. a flat-at-zero series that is
87
+ * invisible in the plot. */
88
+ note?: string
67
89
  }
68
90
 
69
91
  /** Full descriptor = visual identity + data accessors. Generic over the point type. */
70
92
  export type ChartSeries<T> = SeriesStyle & {
71
93
  /** null = line gap + skipped tooltip row. */
72
94
  getValue: (d: T) => number | null
73
- /** Per-series override of the shared tooltip/value formatter. */
74
- 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
75
99
  /** PR star / status dot. Return null for no marker at that point. */
76
100
  getMarker?: (d: T) => { color?: string; r?: number } | null
77
101
  }
@@ -98,7 +122,9 @@ function legendEntryFor(s: SeriesStyle): LegendEntry {
98
122
  dashed: s.dash === 'dashed',
99
123
  ...(s.strokeWidth !== undefined && { strokeWidth: s.strokeWidth }),
100
124
  ...(s.fillOpacity !== undefined && { fillOpacity: s.fillOpacity }),
125
+ ...(s.strokeOpacity !== undefined && { strokeOpacity: s.strokeOpacity }),
101
126
  ...(s.role !== undefined && { role: s.role }),
127
+ ...(s.note !== undefined && { note: s.note }),
102
128
  }
103
129
  }
104
130
 
@@ -136,7 +162,8 @@ export function deriveLegend(series: readonly SeriesStyle[]): LegendEntry[] {
136
162
 
137
163
  /**
138
164
  * Derive tooltip rows from a series descriptor array + the hovered datum. Skips rows where
139
- * `getValue(datum) === null`; honors per-series `formatValue` over the shared `fallbackFormat`.
165
+ * `getValue(datum) === null` or `tooltip === false`; honors per-series `formatValue` over the
166
+ * shared `fallbackFormat`.
140
167
  * Kinds pass `series.toReversed()` for stacked charts so the tooltip stack matches the visual stack.
141
168
  */
142
169
  export function deriveTooltipRows<T>(
@@ -146,14 +173,15 @@ export function deriveTooltipRows<T>(
146
173
  ): TooltipRowData[] {
147
174
  const rows: TooltipRowData[] = []
148
175
  for (const s of series) {
176
+ if (s.tooltip === false) continue
149
177
  const value = s.getValue(datum)
150
178
  if (value === null) continue
151
- const format = s.formatValue ?? fallbackFormat
179
+ const format: (v: number, d: T) => string = s.formatValue ?? fallbackFormat
152
180
  rows.push({
153
181
  key: s.key,
154
182
  label: s.label,
155
183
  color: s.color,
156
- value: format(value),
184
+ value: format(value, datum),
157
185
  shape: shapeFor(s.mark),
158
186
  dashed: s.dash === 'dashed',
159
187
  ...(s.strokeWidth !== undefined && { strokeWidth: s.strokeWidth }),
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { maxTextWidth, measureText, resetTextMetrics } from './measure-text'
3
+
4
+ describe('measureText', () => {
5
+ test('width grows with the string and with the font size', () => {
6
+ resetTextMetrics()
7
+ expect(measureText('1,250,000', 11)).toBeGreaterThan(measureText('0', 11))
8
+ expect(measureText('0', 22)).toBeGreaterThan(measureText('0', 11))
9
+ })
10
+
11
+ test('an empty string measures zero', () => {
12
+ expect(measureText('', 11)).toBe(0)
13
+ })
14
+
15
+ test('repeated measurement is stable — the memo cannot return a different width', () => {
16
+ resetTextMetrics()
17
+ const first = measureText('2026-08-18', 11)
18
+ expect(measureText('2026-08-18', 11)).toBe(first)
19
+ })
20
+
21
+ test('the same text at a different size is NOT a cache hit', () => {
22
+ resetTextMetrics()
23
+ expect(measureText('88', 11)).not.toBe(measureText('88', 30))
24
+ })
25
+ })
26
+
27
+ describe('maxTextWidth', () => {
28
+ test('returns 0 for no labels — an axis with no ticks reserves nothing extra', () => {
29
+ expect(maxTextWidth([], 11)).toBe(0)
30
+ })
31
+
32
+ test('returns the widest label, not the last', () => {
33
+ const labels = ['1', '1,250,000', '25']
34
+ expect(maxTextWidth(labels, 11)).toBe(measureText('1,250,000', 11))
35
+ })
36
+ })
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Text measurement for chart layout — the input to {@link autoMargin}.
3
+ *
4
+ * A static margin token cannot know how wide a tick label is, which is why charts used to have to be
5
+ * hand-nudged whenever a label grew (`docs/CHARTS-SPEC.md` §1). This measures the strings that
6
+ * will actually be painted, via an OFFSCREEN canvas 2D context: no element is attached, so there
7
+ * is no layout thrash and no reflow — unlike an SVG `getBBox()` probe.
8
+ *
9
+ * Mantine-free and dependency-free by construction (`charts/**` boundary).
10
+ */
11
+
12
+ /** Fallback advance width per character, as a fraction of the font size, when no DOM is
13
+ * available (SSR, `renderToStaticMarkup`). Mono faces sit near 0.6em; being deterministic matters
14
+ * more than being exact, since the measured value is only ever a floor-raiser. */
15
+ const FALLBACK_CHAR_RATIO = 0.6
16
+
17
+ /** Last-resort family when the `--basalt-font-mono` custom property cannot be read. */
18
+ const FALLBACK_MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace'
19
+
20
+ /**
21
+ * Cap on the memo table. Tick labels are low-cardinality per chart, but a long-lived SPA with a
22
+ * live counter or currency axis mints a new string every update, and an unbounded module-level Map
23
+ * would then grow for the life of the tab. On overflow the whole table is dropped rather than
24
+ * evicted one by one: the working set is tiny and re-measuring it costs microseconds, so an LRU's
25
+ * bookkeeping would cost more than the misses it avoids.
26
+ */
27
+ const CACHE_LIMIT = 2000
28
+
29
+ const cache = new Map<string, number>()
30
+
31
+ let ctx: CanvasRenderingContext2D | null | undefined
32
+ let monoFamily: string | undefined
33
+
34
+ function canvasContext(): CanvasRenderingContext2D | null {
35
+ if (ctx !== undefined) return ctx
36
+ if (typeof document === 'undefined') {
37
+ ctx = null
38
+ return ctx
39
+ }
40
+ ctx = document.createElement('canvas').getContext('2d')
41
+ // Webfonts swap in after first paint; measurements taken against the fallback face would then
42
+ // be stale (usually too narrow). Drop the cache once, when the real faces are ready.
43
+ // `?.` on every hop: a non-browser DOM harness can expose `document` with no `fonts`, or a
44
+ // `fonts` with no `ready` — neither is a reason to fail a measurement.
45
+ document.fonts?.ready?.then?.(() => cache.clear()).catch(() => {})
46
+ return ctx
47
+ }
48
+
49
+ /**
50
+ * The resolved value of `--basalt-font-mono` — canvas needs a concrete family list, it cannot
51
+ * resolve a CSS custom property. Read once from the document element.
52
+ */
53
+ export function monoFontFamily(): string {
54
+ if (monoFamily !== undefined) return monoFamily
55
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') {
56
+ monoFamily = FALLBACK_MONO
57
+ return monoFamily
58
+ }
59
+ const value = getComputedStyle(document.documentElement)
60
+ .getPropertyValue('--basalt-font-mono')
61
+ .trim()
62
+ monoFamily = value.length > 0 ? value : FALLBACK_MONO
63
+ return monoFamily
64
+ }
65
+
66
+ /**
67
+ * Width in px of `text` at `fontPx` in the chart tick font. Memoized per `font|text` — a chart
68
+ * re-measures the same ~10 tick labels on every resize, and the cache turns that into a Map hit.
69
+ */
70
+ export function measureText(text: string, fontPx: number, fontFamily?: string): number {
71
+ const family = fontFamily ?? monoFontFamily()
72
+ const font = `${fontPx}px ${family}`
73
+ const key = `${font}|${text}`
74
+ const hit = cache.get(key)
75
+ if (hit !== undefined) return hit
76
+
77
+ const c = canvasContext()
78
+ let width: number
79
+ if (c === null) {
80
+ width = text.length * fontPx * FALLBACK_CHAR_RATIO
81
+ } else {
82
+ c.font = font
83
+ width = c.measureText(text).width
84
+ }
85
+ if (cache.size >= CACHE_LIMIT) cache.clear()
86
+ cache.set(key, width)
87
+ return width
88
+ }
89
+
90
+ /** Widest of `texts` at `fontPx`. Returns 0 for an empty list. */
91
+ export function maxTextWidth(
92
+ texts: readonly string[],
93
+ fontPx: number,
94
+ fontFamily?: string,
95
+ ): number {
96
+ let max = 0
97
+ for (const text of texts) {
98
+ const width = measureText(text, fontPx, fontFamily)
99
+ if (width > max) max = width
100
+ }
101
+ return max
102
+ }
103
+
104
+ /** Test seam — drops the memo table (and the resolved family) so a test can change fonts. */
105
+ export function resetTextMetrics(): void {
106
+ cache.clear()
107
+ ctx = undefined
108
+ monoFamily = undefined
109
+ }
@@ -139,9 +139,8 @@ function lookupFenceRenderer(
139
139
 
140
140
  // ── Throw containment ────────────────────────────────────────────────────────────────────────────
141
141
 
142
- /** A `useRef(false)` handle, mutated in place — same per-instance "warn once" idiom as
143
- * `useHoverSync`'s `warnedRef` (`charts/hooks/useHoverSync.ts`) rather than a module-level
144
- * singleton: a singleton would make the FIRST fence anywhere in the process to throw silence the
142
+ /** A `useRef(false)` handle, mutated in place — a per-instance "warn once" gate rather than a
143
+ * module-level singleton: a singleton would make the FIRST fence anywhere in the process to throw silence the
145
144
  * warning for every OTHER, unrelated broken fence for the rest of the run (and, worse, for every
146
145
  * later test in this suite — this file has module-singleton order-dependence history). Scoping the
147
146
  * gate to one `FenceBlock`'s `useRef` means each rendered fence position gets its own warning, reset
package/src/surfaces.ts CHANGED
@@ -211,7 +211,8 @@ export const SURFACES = {
211
211
  rule: 'charts',
212
212
  skill: ['basalt-charts'],
213
213
  guardKinds: ['raw-hex', 'raw-color-fn', 'raw-visx-axis', 'unframed-chart'],
214
- description: 'visx chart primitives, sparklines, hooks, and token re-exports (Mantine-free)',
214
+ description:
215
+ 'CartesianChart + visx chart primitives, kinds, sparklines, hooks, and token re-exports (Mantine-free)',
215
216
  globs: {
216
217
  shipped: ['**/charts/**'],
217
218
  repo: ['packages/basalt-ui/src/charts/**'],
@@ -369,6 +369,7 @@ const SPACE_STEP_SWEEP: ReadonlyArray<
369
369
  ['chartLegendGap', 22, null],
370
370
  ['chartMarginTop', 12, null],
371
371
  ['chartMarginRight', 16, null],
372
+ ['chartMarginRightAxis', 40, null],
372
373
  ['chartMarginBottom', 30, null],
373
374
  ['chartMarginLeft', 44, null],
374
375
  ['chartDotR', 5, null],
@@ -406,6 +407,7 @@ describe('SPACE_STEP CSS-module spacing-sweep one-offs match the shipped identit
406
407
  'chartLegendGap',
407
408
  'chartMarginTop',
408
409
  'chartMarginRight',
410
+ 'chartMarginRightAxis',
409
411
  'chartMarginBottom',
410
412
  'chartMarginLeft',
411
413
  'chartDotR',
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { chartMargin, VX } from './index'
3
+
4
+ describe('chartMargin', () => {
5
+ test('a bare call deep-equals VX.margin', () => {
6
+ expect(chartMargin()).toEqual(VX.margin)
7
+ })
8
+
9
+ test('{ rightAxis: true } widens right to the right-axis minimum', () => {
10
+ expect(chartMargin({ rightAxis: true })).toEqual({
11
+ ...VX.margin,
12
+ right: 40,
13
+ })
14
+ })
15
+
16
+ test('{ rightAxis: false } (or omitted) leaves right at VX.margin.right', () => {
17
+ expect(chartMargin({ rightAxis: false })).toEqual(VX.margin)
18
+ })
19
+
20
+ test('an explicit right overrides the rightAxis-widened value', () => {
21
+ expect(chartMargin({ rightAxis: true, right: 12 })).toEqual({
22
+ ...VX.margin,
23
+ right: 12,
24
+ })
25
+ })
26
+
27
+ test('explicit sides override their VX.margin defaults independently', () => {
28
+ expect(chartMargin({ top: 1, left: 2 })).toEqual({
29
+ top: 1,
30
+ right: VX.margin.right,
31
+ bottom: VX.margin.bottom,
32
+ left: 2,
33
+ })
34
+ })
35
+
36
+ test('returns a new object each call', () => {
37
+ expect(chartMargin()).not.toBe(chartMargin())
38
+ })
39
+ })
@@ -242,6 +242,7 @@ describe('buildDensityCss', () => {
242
242
  'space-chart-legend-gap', // VX.legendGap / VX.margin / VX.dotR — visx SVG props
243
243
  'space-chart-margin-top',
244
244
  'space-chart-margin-right',
245
+ 'space-chart-margin-right-axis',
245
246
  'space-chart-margin-bottom',
246
247
  'space-chart-margin-left',
247
248
  'space-chart-dot-r',
@@ -241,6 +241,42 @@ export const VX = {
241
241
  minPxPerTick: 55,
242
242
  } as const
243
243
 
244
+ /** Resolved chart plot-area margins — the return shape of {@link chartMargin}. */
245
+ export type ChartMargin = { top: number; right: number; bottom: number; left: number }
246
+
247
+ /**
248
+ * Chart plot-area margins — `VX.margin` widened for an `AxisRightNumeric`'s tick labels, and/or
249
+ * overridden per side. A token can't express "only when a right axis is rendered", so this is a
250
+ * function, not a second static token — also the seam that could one day track `density`
251
+ * (`VX.margin` itself is frozen at the level-0 snapshot; see `deriveSpacing`'s JSDoc).
252
+ *
253
+ * Law, applied in this order: start from `VX.margin`; when `rightAxis` is true, widen `right` to
254
+ * `Math.max(VX.margin.right, <right-axis minimum>)` (`SPACE_STEP.chartMarginRightAxis`); then any
255
+ * explicitly passed side wins last (including overriding `rightAxis`'s widened `right`).
256
+ * `chartMargin()` with no args deep-equals `VX.margin`.
257
+ *
258
+ * Returns a NEW object every call — memoize at the call site (`useMemo`), the same way `Bars`
259
+ * already memoizes its own margin.
260
+ */
261
+ export function chartMargin(opts?: {
262
+ /** Reserve room for an `AxisRightNumeric`'s tick labels. */
263
+ rightAxis?: boolean
264
+ top?: number
265
+ right?: number
266
+ bottom?: number
267
+ left?: number
268
+ }): ChartMargin {
269
+ const widenedRight = opts?.rightAxis
270
+ ? Math.max(VX.margin.right, SPACE_STEP.chartMarginRightAxis)
271
+ : VX.margin.right
272
+ return {
273
+ top: opts?.top ?? VX.margin.top,
274
+ right: opts?.right ?? widenedRight,
275
+ bottom: opts?.bottom ?? VX.margin.bottom,
276
+ left: opts?.left ?? VX.margin.left,
277
+ }
278
+ }
279
+
244
280
  type Side = 'light' | 'dark'
245
281
 
246
282
  /** A CSS declaration line for a single `--vx-*` custom property. */
@@ -620,6 +620,9 @@ const SPACE_STEP_BASE = {
620
620
  chartMarginTop: 12,
621
621
  /** Chart plot-area margin, right (`VX.margin.right`). */
622
622
  chartMarginRight: 16,
623
+ /** Minimum right margin when an `AxisRightNumeric` is rendered — labels like "100"/"60m" need
624
+ * more room than the no-right-axis default (`chartMargin({ rightAxis: true })`, `tokens/index.ts`). */
625
+ chartMarginRightAxis: 40,
623
626
  /** Chart plot-area margin, bottom (`VX.margin.bottom`). */
624
627
  chartMarginBottom: 30,
625
628
  /** Chart plot-area margin, left (`VX.margin.left`). */
@@ -1,13 +0,0 @@
1
- export type TooltipState<T> = {
2
- data: T;
3
- x: number;
4
- y: number;
5
- } | null;
6
- export declare function useChartTooltip<T>(): {
7
- tip: TooltipState<T>;
8
- show: (data: T, event: React.MouseEvent) => void;
9
- hide: () => void;
10
- tooltipRef: import("react").RefObject<HTMLDivElement | null>;
11
- lastDateRef: import("react").RefObject<string | null>;
12
- };
13
- //# sourceMappingURL=useChartTooltip.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"useChartTooltip.d.ts","sourceRoot":"","sources":["../../../src/charts/hooks/useChartTooltip.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,YAAY,CAAC,CAAC,IAAI;IAAE,IAAI,EAAE,CAAC,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAA;AAEtE,wBAAgB,eAAe,CAAC,CAAC;;iBAKC,CAAC,SAAS,KAAK,CAAC,UAAU;;;;EAqB3D"}
@@ -1,26 +0,0 @@
1
- import { useCallback, useRef, useState } from "react";
2
- function useChartTooltip() {
3
- const [tip, setTip] = useState(null);
4
- const tooltipRef = useRef(null);
5
- const lastDateRef = useRef(null);
6
- const show = useCallback((data, event) => {
7
- const el = tooltipRef.current;
8
- const width = el?.offsetWidth ?? 0;
9
- const height = el?.offsetHeight ?? 0;
10
- const margin = 8;
11
- const vw = window.innerWidth;
12
- const vh = window.innerHeight;
13
- const overflowsRight = event.clientX + 12 + width + margin > vw;
14
- const x = overflowsRight ? Math.max(margin, event.clientX - 12 - width) : event.clientX + 12;
15
- const y = Math.min(Math.max(margin, event.clientY - 12), vh - height - margin);
16
- setTip({ data, x, y });
17
- }, []);
18
- const hide = useCallback(() => {
19
- setTip(null);
20
- lastDateRef.current = null;
21
- }, []);
22
- return { tip, show, hide, tooltipRef, lastDateRef };
23
- }
24
- export {
25
- useChartTooltip
26
- };