basalt-ui 1.14.0 → 1.15.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 (162) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +38 -2
  4. package/agent/rules/basalt-charts.md +260 -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/configs/oxlint-plugin.js +152 -0
  9. package/configs/oxlint-plugin.test.ts +107 -0
  10. package/configs/oxlint.json +2 -0
  11. package/dist/charts/cursor/resolve.d.ts +33 -0
  12. package/dist/charts/cursor/resolve.d.ts.map +1 -0
  13. package/dist/charts/cursor/resolve.js +61 -0
  14. package/dist/charts/cursor/resolve.test.d.ts +2 -0
  15. package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
  16. package/dist/charts/cursor/scope.d.ts +17 -0
  17. package/dist/charts/cursor/scope.d.ts.map +1 -0
  18. package/dist/charts/cursor/scope.js +20 -0
  19. package/dist/charts/cursor/store.d.ts +31 -0
  20. package/dist/charts/cursor/store.d.ts.map +1 -0
  21. package/dist/charts/cursor/store.js +25 -0
  22. package/dist/charts/cursor/store.test.d.ts +2 -0
  23. package/dist/charts/cursor/store.test.d.ts.map +1 -0
  24. package/dist/charts/hooks/useChartCursor.d.ts +39 -0
  25. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
  26. package/dist/charts/hooks/useChartCursor.js +104 -0
  27. package/dist/charts/hooks/useChartSize.d.ts +8 -4
  28. package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
  29. package/dist/charts/index.d.ts +16 -13
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/index.js +38 -19
  32. package/dist/charts/kinds/Bars.d.ts +25 -47
  33. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  34. package/dist/charts/kinds/Bars.js +230 -390
  35. package/dist/charts/kinds/Donut.d.ts +5 -4
  36. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  37. package/dist/charts/kinds/Donut.js +17 -14
  38. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  39. package/dist/charts/kinds/DualPanel.js +136 -90
  40. package/dist/charts/kinds/Heatmap.d.ts +15 -11
  41. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  42. package/dist/charts/kinds/Heatmap.js +38 -22
  43. package/dist/charts/kinds/MultiLine.d.ts +27 -35
  44. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  45. package/dist/charts/kinds/MultiLine.js +114 -237
  46. package/dist/charts/kinds/StackedArea.d.ts +21 -10
  47. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  48. package/dist/charts/kinds/StackedArea.js +74 -132
  49. package/dist/charts/kinds/ZonedLine.d.ts +24 -40
  50. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  51. package/dist/charts/kinds/ZonedLine.js +103 -184
  52. package/dist/charts/layout/auto-margin.d.ts +60 -0
  53. package/dist/charts/layout/auto-margin.d.ts.map +1 -0
  54. package/dist/charts/layout/auto-margin.js +37 -0
  55. package/dist/charts/layout/auto-margin.test.d.ts +2 -0
  56. package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
  57. package/dist/charts/primitives/CartesianChart.d.ts +143 -0
  58. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
  59. package/dist/charts/primitives/CartesianChart.js +349 -0
  60. package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
  61. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
  62. package/dist/charts/primitives/ChartFrame.d.ts +16 -5
  63. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartFrame.js +17 -2
  65. package/dist/charts/primitives/ChartLegend.d.ts +12 -1
  66. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartLegend.js +21 -3
  68. package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
  69. package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
  70. package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +45 -19
  73. package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
  74. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  75. package/dist/charts/primitives/HoverOverlay.js +22 -1
  76. package/dist/charts/primitives/XZoneRects.d.ts +40 -0
  77. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
  78. package/dist/charts/primitives/XZoneRects.js +28 -0
  79. package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
  80. package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
  81. package/dist/charts/series.d.ts +16 -1
  82. package/dist/charts/series.d.ts.map +1 -1
  83. package/dist/charts/series.js +3 -1
  84. package/dist/charts/series.test.d.ts +2 -0
  85. package/dist/charts/series.test.d.ts.map +1 -0
  86. package/dist/charts/utils/measure-text.d.ts +25 -0
  87. package/dist/charts/utils/measure-text.d.ts.map +1 -0
  88. package/dist/charts/utils/measure-text.js +64 -0
  89. package/dist/charts/utils/measure-text.test.d.ts +2 -0
  90. package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
  91. package/dist/content/fence-block.d.ts.map +1 -1
  92. package/dist/surfaces.d.ts +1 -1
  93. package/dist/surfaces.d.ts.map +1 -1
  94. package/dist/surfaces.js +1 -1
  95. package/dist/tokens/chart-margin.test.d.ts +2 -0
  96. package/dist/tokens/chart-margin.test.d.ts.map +1 -0
  97. package/dist/tokens/index.d.ts +29 -0
  98. package/dist/tokens/index.d.ts.map +1 -1
  99. package/dist/tokens/index.js +10 -0
  100. package/dist/tokens/palette.d.ts +4 -0
  101. package/dist/tokens/palette.d.ts.map +1 -1
  102. package/dist/tokens/palette.js +3 -0
  103. package/llms.txt +3 -3
  104. package/package.json +1 -1
  105. package/src/charts/cursor/resolve.test.ts +61 -0
  106. package/src/charts/cursor/resolve.ts +102 -0
  107. package/src/charts/cursor/scope.tsx +32 -0
  108. package/src/charts/cursor/store.test.ts +46 -0
  109. package/src/charts/cursor/store.ts +53 -0
  110. package/src/charts/hooks/useChartCursor.ts +163 -0
  111. package/src/charts/hooks/useChartSize.ts +8 -4
  112. package/src/charts/index.ts +34 -14
  113. package/src/charts/kinds/Bars.tsx +327 -460
  114. package/src/charts/kinds/Donut.tsx +31 -23
  115. package/src/charts/kinds/DualPanel.tsx +228 -134
  116. package/src/charts/kinds/Heatmap.tsx +72 -47
  117. package/src/charts/kinds/MultiLine.tsx +158 -294
  118. package/src/charts/kinds/StackedArea.tsx +116 -175
  119. package/src/charts/kinds/ZonedLine.tsx +148 -255
  120. package/src/charts/layout/auto-margin.test.ts +42 -0
  121. package/src/charts/layout/auto-margin.ts +110 -0
  122. package/src/charts/primitives/CartesianChart.test.ts +99 -0
  123. package/src/charts/primitives/CartesianChart.tsx +566 -0
  124. package/src/charts/primitives/ChartFrame.test.tsx +33 -0
  125. package/src/charts/primitives/ChartFrame.tsx +40 -4
  126. package/src/charts/primitives/ChartLegend.test.tsx +48 -0
  127. package/src/charts/primitives/ChartLegend.tsx +45 -2
  128. package/src/charts/primitives/ChartTooltip.tsx +67 -37
  129. package/src/charts/primitives/HoverOverlay.tsx +39 -3
  130. package/src/charts/primitives/XZoneRects.test.tsx +63 -0
  131. package/src/charts/primitives/XZoneRects.tsx +67 -0
  132. package/src/charts/series.test.ts +61 -0
  133. package/src/charts/series.ts +18 -1
  134. package/src/charts/utils/measure-text.test.ts +36 -0
  135. package/src/charts/utils/measure-text.ts +109 -0
  136. package/src/content/fence-block.tsx +2 -3
  137. package/src/surfaces.ts +2 -1
  138. package/src/theme/spacing.test.ts +2 -0
  139. package/src/tokens/chart-margin.test.ts +39 -0
  140. package/src/tokens/density.test.ts +1 -0
  141. package/src/tokens/index.ts +36 -0
  142. package/src/tokens/palette.ts +3 -0
  143. package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
  144. package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
  145. package/dist/charts/hooks/useChartTooltip.js +0 -26
  146. package/dist/charts/hooks/useHoverSync.d.ts +0 -46
  147. package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
  148. package/dist/charts/hooks/useHoverSync.js +0 -75
  149. package/dist/charts/hover-context.d.ts +0 -21
  150. package/dist/charts/hover-context.d.ts.map +0 -1
  151. package/dist/charts/hover-context.js +0 -12
  152. package/dist/charts/hover-sync.d.ts +0 -14
  153. package/dist/charts/hover-sync.d.ts.map +0 -1
  154. package/dist/charts/hover-sync.js +0 -17
  155. package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
  156. package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
  157. package/dist/charts/primitives/ResponsiveChart.js +0 -24
  158. package/src/charts/hooks/useChartTooltip.ts +0 -31
  159. package/src/charts/hooks/useHoverSync.ts +0 -152
  160. package/src/charts/hover-context.ts +0 -28
  161. package/src/charts/hover-sync.tsx +0 -37
  162. package/src/charts/primitives/ResponsiveChart.tsx +0 -64
@@ -1,31 +1,15 @@
1
1
  import { curveMonotoneX } from '@visx/curve'
2
- import { GridRows } from '@visx/grid'
3
- import { Group } from '@visx/group'
4
- import { scaleLinear, scalePoint } from '@visx/scale'
5
2
  import { AreaClosed, LinePath } from '@visx/shape'
6
3
  import { Threshold } from '@visx/threshold'
7
4
  import { memo, useMemo } from 'react'
8
- import type { ReactNode } from 'react'
9
5
  import { AreaGradient, areaFillUrl } from '../primitives/AreaGradient'
10
- import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
11
- import {
12
- ChartTooltip,
13
- TooltipBody,
14
- TooltipHeader,
15
- TooltipRow,
16
- useTooltipStyles,
17
- } from '../primitives/ChartTooltip'
18
- import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
19
- import { Crosshair, SeriesDot } from '../primitives/Crosshair'
20
- import { HoverOverlay } from '../primitives/HoverOverlay'
21
- import { ZoneRects } from '../primitives/ZoneRects'
6
+ import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
7
+ import { CartesianChart } from '../primitives/CartesianChart'
8
+ import type { XZoneSpec } from '../primitives/XZoneRects'
22
9
  import type { ZoneSpec } from '../primitives/ZoneRects'
23
- import { useHoverSync } from '../hooks/useHoverSync'
24
- import { deriveTooltipRows, LINE_OVERLAY_STROKE_WIDTH } from '../series'
10
+ import { LINE_OVERLAY_STROKE_WIDTH } from '../series'
25
11
  import type { ChartLegendConfig, ChartSeries } from '../series'
26
12
  import { VX } from '../../tokens'
27
- import { padAutoLower } from '../utils/domain'
28
- import { smartTicks, smartTicksEvery } from '../utils/ticks'
29
13
 
30
14
  /** @deprecated Use ZoneSpec from primitives/ZoneRects. Kept as an alias for back-compat. */
31
15
  export type ZonedLineZone = ZoneSpec
@@ -45,50 +29,31 @@ export type ZonedLineRefLine = {
45
29
  dashed?: boolean
46
30
  }
47
31
 
48
- export type ZonedLineTooltipLabel = {
49
- text: string
50
- color: string
51
- }
52
-
53
32
  export type ZonedLineProps<T> = {
54
33
  data: T[]
55
- /** Fixed height in pixels, forwarded to the internal `ChartFrame`. Default 240. */
34
+ /** Fixed height in pixels, forwarded to `CartesianChart`. Default 240. */
56
35
  height?: number
57
36
  chartId: string
58
37
  /** Extracts the x-axis category (date string) from a data point. */
59
38
  getX: (d: T) => string
60
39
  /** Single-series line — the sole source of truth for color, dash, legend, and tooltip row. Pass
61
- * exactly one entry (kept as an array for parity with the other kinds and so `ChartFrame` /
40
+ * exactly one entry (kept as an array for parity with the other kinds and so `CartesianChart` /
62
41
  * `deriveLegend` / `deriveTooltipRows` can consume it directly). */
63
42
  series: ChartSeries<T>[]
64
- /** Fixed y-domain (e.g. [0, 100]) or 'auto' to compute from data. */
65
- yDomain: [number, number] | 'auto'
66
- /**
67
- * When yDomain is 'auto': the upper bound is at least this value (caps data max).
68
- * e.g. yAutoMaxFloor=2 guarantees the y-axis always reaches 2 even if data is smaller.
69
- */
70
- yAutoMaxFloor?: number
71
- /**
72
- * When yDomain is 'auto': the lower bound is at most this value.
73
- * Default 0 — always includes zero when data is all positive. Pass a negative
74
- * number (or Infinity to disable) for metrics that can legitimately swing both ways.
75
- */
76
- yAutoMinCeil?: number
77
- /** Padding multiplier applied to auto-computed bounds. Default 1.1. The lower bound always pads
78
- * DOWN from `min(dataMin, yAutoMinCeil)` (never up, which would clip data) — see
79
- * {@link padAutoLower}. */
80
- yAutoPad?: number
43
+ /** Y-axis. Collapses the old `yDomain` / `yAutoMaxFloor` / `yAutoMinCeil` / `yAutoPad` /
44
+ * `numTicksY` / `formatYTick` prop soup into one object. */
45
+ y?: AxisConfig<T>
81
46
  zones?: ZonedLineZone[]
47
+ /** Vertical x-range overlays (time windows), rendered behind the line. Bounds are `getX`
48
+ * domain keys. */
49
+ xZones?: XZoneSpec[]
82
50
  thresholds?: ZonedLineThreshold[]
83
51
  refLines?: ZonedLineRefLine[]
84
- numTicksY?: number
85
- numTicksX?: number
86
- /** Label shown at the right of the tooltip header (e.g. zone name with zone color). */
87
- tooltipLabel?: (d: T) => ZonedLineTooltipLabel | null
88
- /** Formatter for the tooltip value. */
89
- formatValue: (v: number) => string
90
- /** Optional extra tooltip rows (rendered after the main row). */
91
- renderExtraTooltipRows?: (d: T) => ReactNode
52
+ /** Exact number of x ticks. Default: as many as fit. */
53
+ xTicks?: number
54
+ /** Tooltip config — `label` for a right-aligned header badge (e.g. zone name with zone color),
55
+ * `extraRows` for rows appended after the derived row. `false` disables the tooltip entirely. */
56
+ tooltip?: CartesianTooltipConfig<T> | false
92
57
  /**
93
58
  * Opt-in soft gradient fill under the line. Pass a color token to tint the area
94
59
  * with that hue — the modern single-hue look. `true` falls back to the series color. Off by
@@ -96,83 +61,124 @@ export type ZonedLineProps<T> = {
96
61
  * via `--vx-area-top` / `--vx-area-bottom` (tunable in the dev theme lab).
97
62
  */
98
63
  areaFill?: string | boolean
99
- /** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
64
+ /** Legend config forwarded to `CartesianChart`; `false` disables the legend (sparkline escape).
100
65
  * Default `{ placement: 'bottom' }`. */
101
66
  legend?: ChartLegendConfig | false
102
- /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
67
+ /** Accessible text alternative, forwarded to `CartesianChart` as `aria-label` (+ `role="img"`). */
103
68
  ariaLabel?: string
104
- /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
69
+ /** Forwarded to `CartesianChart` — see `ChartPending`'s JSDoc for the three-state rationale. */
105
70
  isPending?: boolean
106
71
  }
107
72
 
73
+ type Valid<T> = T & { __y: number }
74
+
75
+ /** The primary (and only) series' valid points — null values are dropped, creating line gaps. */
76
+ function validPoints<T>(series: ChartSeries<T> | undefined, data: readonly T[]): Valid<T>[] {
77
+ if (!series) return []
78
+ const out: Valid<T>[] = []
79
+ for (const d of data) {
80
+ const y = series.getValue(d)
81
+ if (y !== null && y !== undefined && !Number.isNaN(y)) {
82
+ out.push(Object.assign({}, d, { __y: y }) as Valid<T>)
83
+ }
84
+ }
85
+ return out
86
+ }
87
+
108
88
  /**
109
- * Line chart with zone backgrounds, threshold fills, reference lines, and a
110
- * shared-cursor tooltip. Covers the line-with-zones pattern. Does NOT handle
111
- * dual-panel charts (keep those bespoke).
89
+ * Single-series line chart with zone backgrounds, threshold fills, an optional area gradient,
90
+ * reference lines, and a shared-cursor tooltip. Covers the line-with-zones pattern. Does NOT
91
+ * handle dual-panel charts (keep those bespoke).
112
92
  *
113
- * Composes `ChartFrame` for measuring + the derived legend — single-series, so the legend is
114
- * optional in practice but present by default (gained relative to the pre-redesign kind).
93
+ * Composes `CartesianChart` for measuring, scales, grid, zones/x-zones, axes, the shared cursor,
94
+ * and the derived tooltip — this kind draws ONLY the marks (thresholds, area fill, and the line).
95
+ * Single-series, so the legend is optional in practice but present by default.
115
96
  *
116
97
  * X-axis is built from the full `data` array so the calendar is preserved even
117
98
  * when the series has nulls; the line itself skips null points (creating
118
99
  * visual gaps).
119
100
  */
120
101
  function ZonedLineInner<T>(props: ZonedLineProps<T>) {
121
- const { series, chartId, height, legend, ariaLabel, isPending } = props
102
+ const {
103
+ data,
104
+ chartId,
105
+ getX,
106
+ series,
107
+ y,
108
+ zones,
109
+ xZones,
110
+ thresholds,
111
+ refLines,
112
+ xTicks,
113
+ tooltip,
114
+ areaFill,
115
+ height,
116
+ legend,
117
+ ariaLabel,
118
+ isPending,
119
+ } = props
122
120
 
123
121
  // Default the line overlay to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied
124
122
  // once here so the plotted line, the derived legend swatch, and the derived tooltip row agree.
125
123
  const styledSeries = useMemo<ChartSeries<T>[]>(
126
- () => series.map((s) => ({ ...s, strokeWidth: s.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH })),
124
+ () =>
125
+ series.map((s) => ({
126
+ ...s,
127
+ strokeWidth: s.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH,
128
+ })),
127
129
  [series],
128
130
  )
129
131
 
130
132
  return (
131
- <ChartFrame
132
- series={styledSeries}
133
+ <CartesianChart
134
+ data={data}
133
135
  chartId={chartId}
136
+ getX={getX}
137
+ series={styledSeries}
138
+ {...(y !== undefined && { y })}
139
+ {...(zones !== undefined && { zones })}
140
+ {...(xZones !== undefined && { xZones })}
141
+ {...(refLines !== undefined && { refLines })}
142
+ {...(xTicks !== undefined && { xTicks })}
143
+ {...(tooltip !== undefined && { tooltip })}
134
144
  {...(height !== undefined && { height })}
145
+ {...(legend !== undefined && { legend })}
135
146
  {...(ariaLabel !== undefined && { ariaLabel })}
136
147
  {...(isPending !== undefined && { isPending })}
137
- legend={resolveLegend(legend)}
138
148
  >
139
- {(plot) => <ZonedLinePlot {...props} series={styledSeries} plot={plot} />}
140
- </ChartFrame>
149
+ {(ctx: PlotContext<T>) => (
150
+ <ZonedLineMarks
151
+ getX={getX}
152
+ chartId={chartId}
153
+ thresholds={thresholds ?? []}
154
+ areaFill={areaFill}
155
+ ctx={ctx}
156
+ />
157
+ )}
158
+ </CartesianChart>
141
159
  )
142
160
  }
143
161
 
144
- type ZonedLinePlotProps<T> = ZonedLineProps<T> & {
145
- plot: { width: number; height: number }
146
- }
147
-
148
- /** The measured plot — split from {@link ZonedLineInner} so its scale/hover-sync hooks only run
149
- * once `ChartFrame` has resolved a non-empty plot rect. */
150
- function ZonedLinePlot<T>(props: ZonedLinePlotProps<T>) {
151
- const {
152
- data,
153
- chartId,
154
- getX,
155
- series,
156
- yDomain,
157
- yAutoPad = 1.1,
158
- yAutoMaxFloor,
159
- yAutoMinCeil = 0,
160
- zones = [],
161
- thresholds = [],
162
- refLines = [],
163
- numTicksY = 5,
164
- numTicksX,
165
- tooltipLabel,
166
- formatValue,
167
- renderExtraTooltipRows,
168
- areaFill,
169
- plot,
170
- } = props
171
-
172
- const primary = series[0]
173
- const MARGIN = VX.margin
174
- const xMax = plot.width - MARGIN.left - MARGIN.right
175
- const yMax = plot.height - MARGIN.top - MARGIN.bottom
162
+ /** Draws the threshold fills, the optional area gradient, and the line for the (single) visible
163
+ * series. Hidden via the legend toggle draws nothing, same as every other kind. */
164
+ function ZonedLineMarks<T>({
165
+ getX,
166
+ chartId,
167
+ thresholds,
168
+ areaFill,
169
+ ctx,
170
+ }: {
171
+ getX: (d: T) => string
172
+ chartId: string
173
+ thresholds: ZonedLineThreshold[]
174
+ areaFill: string | boolean | undefined
175
+ ctx: PlotContext<T>
176
+ }) {
177
+ const { data, visible, xScale, yScale, yMax } = ctx
178
+ const primary = visible[0]
179
+ // Memoized: the shared cursor re-renders every chart on every pointer frame, and this both
180
+ // walks the full series and clones an object per point.
181
+ const valid = useMemo(() => validPoints(primary, data), [primary, data])
176
182
 
177
183
  // Area is opt-in: pass a color token to get a cohesive single-hue fill under the line.
178
184
  // (A neutral fill under the neutral line just reads as grey haze, so there is no default-on.)
@@ -180,164 +186,51 @@ function ZonedLinePlot<T>(props: ZonedLinePlotProps<T>) {
180
186
  const areaColor = typeof areaFill === 'string' ? areaFill : primary?.color
181
187
  const areaId = `${chartId}-area`
182
188
 
183
- type Valid = T & { __y: number }
184
- const valid = useMemo<Valid[]>(() => {
185
- const out: Valid[] = []
186
- for (const d of data) {
187
- const y = primary?.getValue(d) ?? null
188
- if (y !== null && y !== undefined && !Number.isNaN(y)) {
189
- out.push(Object.assign({}, d, { __y: y }) as Valid)
190
- }
191
- }
192
- return out
193
- }, [data, primary])
194
-
195
- const xScale = useMemo(
196
- () =>
197
- scalePoint<string>({
198
- // Full calendar — axis does not compress across nulls.
199
- domain: data.map(getX),
200
- range: [0, xMax],
201
- padding: 0.3,
202
- }),
203
- [data, xMax, getX],
204
- )
205
-
206
- const yScale = useMemo(() => {
207
- if (yDomain === 'auto') {
208
- const ys = valid.map((d) => d.__y)
209
- const dataMax = ys.length ? Math.max(...ys) : 0
210
- const dataMin = ys.length ? Math.min(...ys) : 0
211
- const upper = Math.max(dataMax, yAutoMaxFloor ?? dataMax) * yAutoPad
212
- const lower = padAutoLower(Math.min(dataMin, yAutoMinCeil), yAutoPad)
213
- return scaleLinear<number>({ domain: [lower, upper], range: [yMax, 0], nice: true })
214
- }
215
- return scaleLinear<number>({ domain: yDomain, range: [yMax, 0] })
216
- }, [valid, yDomain, yMax, yAutoPad, yAutoMaxFloor, yAutoMinCeil])
217
-
218
- const tooltipStyles = useTooltipStyles()
219
- const { tip, tooltipRef, syncedPoint, isDirectHover, handleMouse, handleLeave } =
220
- useHoverSync<Valid>({
221
- data: valid,
222
- chartId,
223
- getKey: getX,
224
- xScale,
225
- marginLeft: MARGIN.left,
226
- })
227
-
228
- const tickValues = useMemo(
229
- () =>
230
- numTicksX ? smartTicksEvery(data.map(getX), numTicksX) : smartTicks(data.map(getX), xMax),
231
- [data, xMax, getX, numTicksX],
232
- )
233
-
234
- const tooltipLbl = tip ? (tooltipLabel?.(tip.data) ?? null) : null
235
-
236
189
  return (
237
190
  <>
238
- <svg width={plot.width} height={plot.height}>
239
- <Group left={MARGIN.left} top={MARGIN.top}>
240
- <GridRows scale={yScale} width={xMax} stroke={VX.grid} numTicks={numTicksY} />
241
-
242
- <ZoneRects zones={zones} width={xMax} leftScale={yScale} />
243
-
244
- {thresholds.map((t, i) => (
245
- <Threshold<Valid>
246
- key={`thr-${i}`}
247
- id={`${chartId}-thr-${i}`}
248
- data={valid}
249
- x={(d) => xScale(getX(d)) ?? 0}
250
- y0={() => yScale(t.value)}
251
- y1={(d) => yScale(d.__y)}
252
- clipAboveTo={0}
253
- clipBelowTo={yMax}
254
- curve={curveMonotoneX}
255
- belowAreaProps={{ fill: t.side === 'above' ? t.fill : 'transparent' }}
256
- aboveAreaProps={{ fill: t.side === 'below' ? t.fill : 'transparent' }}
257
- />
258
- ))}
259
-
260
- {showArea && areaColor !== undefined && (
261
- <>
262
- <defs>
263
- <AreaGradient id={areaId} color={areaColor} />
264
- </defs>
265
- <AreaClosed<Valid>
266
- data={valid}
267
- x={(d) => xScale(getX(d)) ?? 0}
268
- y={(d) => yScale(d.__y)}
269
- yScale={yScale}
270
- curve={curveMonotoneX}
271
- fill={areaFillUrl(areaId)}
272
- />
273
- </>
274
- )}
275
-
276
- {refLines.map((r, i) => (
277
- <line
278
- key={`ref-${i}`}
279
- x1={0}
280
- x2={xMax}
281
- y1={yScale(r.value)}
282
- y2={yScale(r.value)}
283
- stroke={r.color}
284
- strokeDasharray={r.dashed ? VX.dashArray : undefined}
285
- />
286
- ))}
287
-
288
- {primary && (
289
- <LinePath<Valid>
290
- data={valid}
291
- x={(d) => xScale(getX(d)) ?? 0}
292
- y={(d) => yScale(d.__y)}
293
- stroke={primary.color}
294
- strokeWidth={primary.strokeWidth ?? VX.lineWidth}
295
- strokeDasharray={primary.dash === 'dashed' ? VX.dashArray : undefined}
296
- curve={curveMonotoneX}
297
- />
298
- )}
299
-
300
- {syncedPoint && primary && (
301
- <>
302
- <Crosshair x={xScale(getX(syncedPoint)) ?? 0} top={0} bottom={yMax} />
303
- <SeriesDot
304
- cx={xScale(getX(syncedPoint)) ?? 0}
305
- cy={yScale(syncedPoint.__y)}
306
- color={primary.color}
307
- />
308
- </>
309
- )}
310
-
311
- <AxisLeftNumeric scale={yScale} numTicks={numTicksY} />
312
- <AxisBottomDate top={yMax} scale={xScale} tickValues={tickValues} />
313
-
314
- <HoverOverlay width={xMax} height={yMax} onMove={handleMouse} onLeave={handleLeave} />
315
- </Group>
316
- </svg>
317
- <ChartTooltip tip={isDirectHover ? tip : null} tooltipRef={tooltipRef} styles={tooltipStyles}>
318
- {tip && isDirectHover && (
319
- <>
320
- <TooltipHeader
321
- date={getX(tip.data)}
322
- {...(tooltipLbl !== null && { label: tooltipLbl.text, labelColor: tooltipLbl.color })}
323
- />
324
- <TooltipBody>
325
- {deriveTooltipRows(series, tip.data, formatValue).map((row) => (
326
- <TooltipRow
327
- key={row.key}
328
- color={row.color}
329
- label={row.label}
330
- value={row.value}
331
- shape={row.shape}
332
- dashed={row.dashed}
333
- {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
334
- />
335
- ))}
336
- {renderExtraTooltipRows?.(tip.data)}
337
- </TooltipBody>
338
- </>
339
- )}
340
- </ChartTooltip>
191
+ {thresholds.map((t, i) => (
192
+ <Threshold<Valid<T>>
193
+ key={`thr-${i}`}
194
+ id={`${chartId}-thr-${i}`}
195
+ data={valid}
196
+ x={(d) => xScale(getX(d)) ?? 0}
197
+ y0={() => yScale(t.value)}
198
+ y1={(d) => yScale(d.__y)}
199
+ clipAboveTo={0}
200
+ clipBelowTo={yMax}
201
+ curve={curveMonotoneX}
202
+ belowAreaProps={{ fill: t.side === 'above' ? t.fill : 'transparent' }}
203
+ aboveAreaProps={{ fill: t.side === 'below' ? t.fill : 'transparent' }}
204
+ />
205
+ ))}
206
+
207
+ {showArea && areaColor !== undefined && (
208
+ <>
209
+ <defs>
210
+ <AreaGradient id={areaId} color={areaColor} />
211
+ </defs>
212
+ <AreaClosed<Valid<T>>
213
+ data={valid}
214
+ x={(d) => xScale(getX(d)) ?? 0}
215
+ y={(d) => yScale(d.__y)}
216
+ yScale={yScale}
217
+ curve={curveMonotoneX}
218
+ fill={areaFillUrl(areaId)}
219
+ />
220
+ </>
221
+ )}
222
+
223
+ {primary && (
224
+ <LinePath<Valid<T>>
225
+ data={valid}
226
+ x={(d) => xScale(getX(d)) ?? 0}
227
+ y={(d) => yScale(d.__y)}
228
+ stroke={primary.color}
229
+ strokeWidth={primary.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
230
+ strokeDasharray={primary.dash === 'dashed' ? VX.dashArray : undefined}
231
+ curve={curveMonotoneX}
232
+ />
233
+ )}
341
234
  </>
342
235
  )
343
236
  }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { VX } from '../../tokens'
3
+ import { autoMargin } from './auto-margin'
4
+
5
+ describe('autoMargin', () => {
6
+ test('never goes below the VX.margin floor', () => {
7
+ const m = autoMargin()
8
+ expect(m.top).toBe(VX.margin.top)
9
+ expect(m.right).toBeGreaterThanOrEqual(VX.margin.right)
10
+ expect(m.bottom).toBeGreaterThanOrEqual(VX.margin.bottom)
11
+ expect(m.left).toBeGreaterThanOrEqual(VX.margin.left)
12
+ })
13
+
14
+ test('a wide left label widens the left gutter', () => {
15
+ const narrow = autoMargin({ left: ['0', '5'] })
16
+ const wide = autoMargin({ left: ['0', '1,250,000'] })
17
+ expect(wide.left).toBeGreaterThan(narrow.left)
18
+ })
19
+
20
+ test('right-axis labels widen the right gutter past the floor', () => {
21
+ const m = autoMargin({ right: ['$1,000.00'] })
22
+ expect(m.right).toBeGreaterThan(VX.margin.right)
23
+ })
24
+
25
+ test('without a right axis, the right gutter still reserves half the widest x label', () => {
26
+ // The last x tick is centred on the plot's right edge, so half its label hangs past it.
27
+ const m = autoMargin({ bottom: ['2026-08-18T12:00'] })
28
+ expect(m.right).toBeGreaterThan(VX.margin.right)
29
+ })
30
+
31
+ test('rotated x labels deepen the bottom gutter', () => {
32
+ const flat = autoMargin({ bottom: ['a long category label'] })
33
+ const rotated = autoMargin({ bottom: ['a long category label'], rotate: 45 })
34
+ expect(rotated.bottom).toBeGreaterThan(flat.bottom)
35
+ })
36
+
37
+ test('an explicit override wins over measurement', () => {
38
+ const m = autoMargin({ left: ['1,250,000'], override: { left: 4, top: 0 } })
39
+ expect(m.left).toBe(4)
40
+ expect(m.top).toBe(0)
41
+ })
42
+ })
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Plot-rect margins derived from the labels that will actually be painted.
3
+ *
4
+ * `VX.margin` is a static token, so charts used to clip (or get hand-nudged) whenever a tick label
5
+ * outgrew it — the "I always push the charts around" failure mode. Here the token becomes a
6
+ * FLOOR: a measured side may only ever grow past it, never shrink below it, so no chart gets
7
+ * tighter than it was, and a wide label widens its own gutter automatically.
8
+ *
9
+ * See `docs/CHARTS-SPEC.md` §1.
10
+ */
11
+
12
+ import { scaleLinear } from '@visx/scale'
13
+ import { VX } from '../../tokens'
14
+ import type { ChartMargin } from '../../tokens'
15
+ import { maxTextWidth } from '../utils/measure-text'
16
+
17
+ /**
18
+ * Distance from the plot edge to the near edge of a tick LABEL: visx's default `tickLength` (8)
19
+ * plus the ±4 `dx` the themed axes in `primitives/Axes.tsx` apply. Kept in one place so the
20
+ * measured gutter and the painted axis agree.
21
+ */
22
+ const AXIS_TICK_GAP = 12
23
+
24
+ /** Line box of a single-line tick label at `fontPx`. */
25
+ const lineHeight = (fontPx: number): number => Math.ceil(fontPx * 1.35)
26
+
27
+ export type AutoMarginInput = {
28
+ /** Left-axis tick labels, already formatted. */
29
+ left?: readonly string[]
30
+ /** Right-axis tick labels, already formatted. Presence (not a flag) is what widens `right`. */
31
+ right?: readonly string[]
32
+ /** X-axis tick labels, already formatted. */
33
+ bottom?: readonly string[]
34
+ /** Degrees of counter-clockwise rotation applied to the x tick labels. Default 0. */
35
+ rotate?: number
36
+ /** Tick-label font size. Default `VX.axisFont`. */
37
+ fontPx?: number
38
+ /** Explicit per-side overrides — applied LAST, so an escape hatch always wins. */
39
+ override?: Partial<ChartMargin>
40
+ }
41
+
42
+ /**
43
+ * Resolve the four margins from measured labels.
44
+ *
45
+ * Law, per side:
46
+ * - `left` — widest left label + gap, floored at `VX.margin.left`.
47
+ * - `right` — with a right axis: widest right label + gap. Without one: half the widest x label,
48
+ * so the LAST x tick (centered on the plot's right edge) cannot clip. Both floored at
49
+ * `VX.margin.right`.
50
+ * - `bottom` — one label line (or the rotated bounding height) + gap, floored at
51
+ * `VX.margin.bottom`.
52
+ * - `top` — `VX.margin.top`. Nothing measures into it; a chart that draws into the top gutter
53
+ * passes an override.
54
+ */
55
+ export function autoMargin(input: AutoMarginInput = {}): ChartMargin {
56
+ const fontPx = input.fontPx ?? VX.axisFont
57
+ const { left = [], right = [], bottom = [], rotate = 0, override } = input
58
+
59
+ const leftWidth = maxTextWidth(left, fontPx)
60
+ const rightWidth = maxTextWidth(right, fontPx)
61
+ const bottomWidth = maxTextWidth(bottom, fontPx)
62
+
63
+ const radians = (rotate * Math.PI) / 180
64
+ const bottomExtent =
65
+ rotate === 0
66
+ ? lineHeight(fontPx)
67
+ : Math.ceil(
68
+ Math.abs(bottomWidth * Math.sin(radians)) +
69
+ Math.abs(lineHeight(fontPx) * Math.cos(radians)),
70
+ )
71
+
72
+ return {
73
+ top: override?.top ?? VX.margin.top,
74
+ right:
75
+ override?.right ??
76
+ Math.ceil(
77
+ Math.max(VX.margin.right, right.length > 0 ? rightWidth + AXIS_TICK_GAP : bottomWidth / 2),
78
+ ),
79
+ bottom: override?.bottom ?? Math.ceil(Math.max(VX.margin.bottom, bottomExtent + AXIS_TICK_GAP)),
80
+ left:
81
+ override?.left ??
82
+ Math.ceil(Math.max(VX.margin.left, left.length > 0 ? leftWidth + AXIS_TICK_GAP : 0)),
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The tick labels a linear axis WILL paint, derived before the plot rect exists.
88
+ *
89
+ * This is what breaks the margin/scale circularity: a linear scale's tick VALUES depend only on
90
+ * its domain, so a throwaway-range probe yields the exact label strings {@link autoMargin} needs
91
+ * to measure — and returning the resolved `format` alongside them guarantees the measured string
92
+ * and the painted string are the same one. Shared by `CartesianChart` and by hand-composed kinds
93
+ * (`DualPanel`) so the two can't drift.
94
+ */
95
+ export function probeAxisLabels(opts: {
96
+ domain: [number, number]
97
+ ticks: number
98
+ /** Overrides d3's own tick format. */
99
+ format?: (v: number) => string
100
+ /** Must match the real scale's `nice` — niceing changes which ticks exist. */
101
+ nice?: boolean
102
+ }): { labels: string[]; format: (v: number) => string } {
103
+ const probe = scaleLinear<number>({
104
+ domain: opts.domain,
105
+ range: [1, 0],
106
+ ...(opts.nice === true && { nice: true }),
107
+ })
108
+ const format = opts.format ?? probe.tickFormat(opts.ticks)
109
+ return { labels: probe.ticks(opts.ticks).map(format), format }
110
+ }