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
@@ -1,73 +1,51 @@
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 { LinePath } from '@visx/shape'
6
- import { memo, useMemo, useState } from 'react'
3
+ import { memo, useCallback, useMemo } from 'react'
7
4
  import type { ReactNode } from 'react'
8
- import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
9
- import {
10
- ChartTooltip,
11
- TooltipBody,
12
- TooltipHeader,
13
- TooltipRow,
14
- useTooltipStyles,
15
- } from '../primitives/ChartTooltip'
16
- import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
17
- import { Crosshair, SeriesDot } from '../primitives/Crosshair'
18
- import { HoverOverlay } from '../primitives/HoverOverlay'
19
- import { ZoneRects } from '../primitives/ZoneRects'
5
+ import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
6
+ import { CartesianChart } from '../primitives/CartesianChart'
7
+ import type { XZoneSpec } from '../primitives/XZoneRects'
20
8
  import type { ZoneSpec } from '../primitives/ZoneRects'
21
- import { useHoverSync } from '../hooks/useHoverSync'
22
- import { deriveTooltipRows, LINE_OVERLAY_STROKE_WIDTH } from '../series'
9
+ import { LINE_OVERLAY_STROKE_WIDTH } from '../series'
23
10
  import type { ChartLegendConfig, ChartSeries } from '../series'
24
11
  import { VX } from '../../tokens'
25
- import { padAutoLower } from '../utils/domain'
26
- import { smartTicks, smartTicksEvery } from '../utils/ticks'
27
12
 
28
13
  export type MultiLineProps<T> = {
29
14
  data: T[]
30
- /** Fixed height in pixels, forwarded to the internal `ChartFrame`. Default 240. */
15
+ /** Fixed height in pixels, forwarded to `CartesianChart`. Default 240. */
31
16
  height?: number
32
17
  chartId: string
33
18
  /** Extracts the x-axis category (date string) from a data point. */
34
19
  getX: (d: T) => string
35
- /** 1+ line series sharing one y-axis — the single source of truth for color, dash, legend, and
36
- * tooltip rows. */
20
+ /** 1+ line series sharing the y-axis(es) — the single source of truth for color, dash, legend,
21
+ * and tooltip rows. A series opts into the right axis with `axis: 'right'`. */
37
22
  series: ChartSeries<T>[]
38
- /** Fixed y-domain (e.g. [-2.5, 2.5]) or 'auto' to compute from all series. */
39
- yDomain: [number, number] | 'auto'
40
- /** When 'auto': upper bound is at least this value. */
41
- yAutoMaxFloor?: number
42
- /** When 'auto': lower bound is at most this value. Default 0. Pass `Infinity` to disable the
43
- * forced zero baseline — the lower bound then pads DOWN from the raw data minimum instead. */
44
- yAutoMinCeil?: number
45
- /** When 'auto': padding multiplier applied away from the domain. Default 1.1. The lower bound
46
- * always pads DOWN from `min(dataMin, yAutoMinCeil)` (never up, which would clip data) — see
47
- * {@link padAutoLower}. */
48
- yAutoPad?: number
49
- /** Horizontal value-range overlays (target zones), rendered behind the lines on the left scale. */
23
+ /** Left y-axis. Collapses the old `yDomain` / `yAutoMaxFloor` / `yAutoMinCeil` / `yAutoPad` /
24
+ * `numTicksY` / `formatYTick` prop soup into one object. */
25
+ y?: AxisConfig<T>
26
+ /** Right y-axis. Passing it is what makes the chart dual-axis — the margin widens by
27
+ * measurement, and `axis: 'right'` series read against it. */
28
+ y2?: AxisConfig<T>
29
+ /** Horizontal value-range overlays (target zones), rendered behind the lines. */
50
30
  zones?: ZoneSpec[]
31
+ /** Vertical x-range overlays (time windows), rendered behind the lines. Bounds are `getX`
32
+ * domain keys. */
33
+ xZones?: XZoneSpec[]
51
34
  /** Horizontal reference lines. Solid by default; set dashed: true for a dashed line. */
52
35
  refLines?: { value: number; color: string; dashed?: boolean }[]
53
- numTicksX?: number
54
- numTicksY?: number
55
- /** Tooltip + (per-series) value formatter. */
56
- formatValue: (v: number) => string
57
- /** Y-axis tick label formatter. Falls back to the scale's default. */
58
- formatYTick?: (v: number) => string
59
- /** Label shown at the right of the tooltip header (e.g. status badge). */
60
- tooltipLabel?: (d: T) => { text: string; color: string } | null
61
- /** Extra tooltip rows rendered after the per-series rows. */
62
- renderExtraTooltipRows?: (d: T) => ReactNode
36
+ /** Exact number of x ticks. Default: as many as fit. */
37
+ xTicks?: number
38
+ /** Tooltip config — `label` for a right-aligned header badge, `extraRows` for rows appended
39
+ * after the derived per-series rows. `false` disables the tooltip entirely. */
40
+ tooltip?: CartesianTooltipConfig<T> | false
63
41
  /** Marker glyph for per-point markers. Default 'circle'. */
64
42
  markerShape?: 'circle' | 'star'
65
- /** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
43
+ /** Legend config forwarded to `CartesianChart`; `false` disables the legend (sparkline escape).
66
44
  * Default `{ placement: 'bottom' }`. */
67
45
  legend?: ChartLegendConfig | false
68
- /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
46
+ /** Accessible text alternative, forwarded to `CartesianChart` as `aria-label` (+ `role="img"`). */
69
47
  ariaLabel?: string
70
- /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
48
+ /** Forwarded to `CartesianChart` — see `ChartPending`'s JSDoc for the three-state rationale. */
71
49
  isPending?: boolean
72
50
  }
73
51
 
@@ -85,21 +63,50 @@ function starPath(cx: number, cy: number, r: number): string {
85
63
  return `M${pts.join('L')}Z`
86
64
  }
87
65
 
66
+ type LinePt<T> = { __d: T; __y: number }
67
+
68
+ /** Per-series valid points (null-value gaps dropped). Reused by the lines and the markers. */
69
+ function seriesPoints<T>(series: ChartSeries<T>, data: readonly T[]): LinePt<T>[] {
70
+ const pts: LinePt<T>[] = []
71
+ for (const d of data) {
72
+ const v = series.getValue(d)
73
+ if (v !== null && v !== undefined && !Number.isNaN(v)) pts.push({ __d: d, __y: v })
74
+ }
75
+ return pts
76
+ }
77
+
88
78
  /**
89
- * N series sharing one y-axis, with optional zones/reference lines, per-point markers
79
+ * N series sharing one or two y-axes, with optional zones/reference lines, per-point markers
90
80
  * (PR stars / status dots), a dashed companion line per series, legend-hover dimming, and a
91
81
  * shared-cursor tooltip. Generalizes the multi-line argo charts (e1RM trend, relative
92
82
  * progression, training load, fitness trends).
93
83
  *
94
- * Composes `ChartFrame` for measuring + the derived legend — `series` is the single array that
95
- * drives the plotted lines, the legend, and the tooltip rows.
84
+ * Composes `CartesianChart` for measuring, scales, grid, axes, cursor, and tooltip — this kind
85
+ * draws ONLY the marks (lines + per-point markers). `series` is the single array that drives the
86
+ * plotted lines, the legend, and the tooltip rows.
96
87
  *
97
88
  * X-axis is built from the full `data` array so the calendar is preserved even when a series
98
89
  * has nulls; each series line skips null points (creating visual gaps).
99
90
  */
100
91
  function MultiLineInner<T>(props: MultiLineProps<T>) {
101
- const { series, chartId, height, legend, ariaLabel, isPending } = props
102
- const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
92
+ const {
93
+ data,
94
+ chartId,
95
+ getX,
96
+ series,
97
+ y,
98
+ y2,
99
+ zones,
100
+ xZones,
101
+ refLines,
102
+ xTicks,
103
+ tooltip,
104
+ markerShape = 'circle',
105
+ height,
106
+ legend,
107
+ ariaLabel,
108
+ isPending,
109
+ } = props
103
110
 
104
111
  // Default line overlays to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied once
105
112
  // here so the plotted line, the derived legend swatch, and the derived tooltip row all agree.
@@ -109,265 +116,139 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
109
116
  )
110
117
 
111
118
  return (
112
- <ChartFrame
113
- series={styledSeries}
119
+ <CartesianChart
120
+ data={data}
114
121
  chartId={chartId}
122
+ getX={getX}
123
+ series={styledSeries}
124
+ {...(y !== undefined && { y })}
125
+ {...(y2 !== undefined && { y2 })}
126
+ {...(zones !== undefined && { zones })}
127
+ {...(xZones !== undefined && { xZones })}
128
+ {...(refLines !== undefined && { refLines })}
129
+ {...(xTicks !== undefined && { xTicks })}
130
+ {...(tooltip !== undefined && { tooltip })}
115
131
  {...(height !== undefined && { height })}
132
+ {...(legend !== undefined && { legend })}
116
133
  {...(ariaLabel !== undefined && { ariaLabel })}
117
134
  {...(isPending !== undefined && { isPending })}
118
- legend={resolveLegend(legend, {
119
- highlighted: highlightedKey,
120
- onHighlight: setHighlightedKey,
121
- })}
122
135
  >
123
- {(plot) => (
124
- <MultiLinePlot
125
- {...props}
126
- series={styledSeries}
127
- plot={plot}
128
- highlightedKey={highlightedKey}
129
- />
130
- )}
131
- </ChartFrame>
136
+ {(ctx: PlotContext<T>) => <MultiLineMarks getX={getX} markerShape={markerShape} ctx={ctx} />}
137
+ </CartesianChart>
132
138
  )
133
139
  }
134
140
 
135
- type MultiLinePlotProps<T> = MultiLineProps<T> & {
136
- plot: { width: number; height: number }
137
- highlightedKey: string | null
138
- }
141
+ /** Draws lines + per-point markers for the visible series, dimming everything except the
142
+ * legend-highlighted series (and its dashed companions, matched by `parent`). */
143
+ function MultiLineMarks<T>({
144
+ getX,
145
+ markerShape,
146
+ ctx,
147
+ }: {
148
+ getX: (d: T) => string
149
+ markerShape: 'circle' | 'star'
150
+ ctx: PlotContext<T>
151
+ }) {
152
+ const { data, visible, xScale, yScale, y2Scale, highlighted } = ctx
139
153
 
140
- /** The measured plot — split from {@link MultiLineInner} so its scale/hover-sync hooks only run
141
- * once `ChartFrame` has resolved a non-empty plot rect (hooks can't run inside a conditionally
142
- * invoked render-prop callback). */
143
- function MultiLinePlot<T>(props: MultiLinePlotProps<T>) {
144
- const {
145
- data,
146
- chartId,
147
- getX,
148
- series,
149
- yDomain,
150
- yAutoPad = 1.1,
151
- yAutoMaxFloor,
152
- yAutoMinCeil = 0,
153
- zones = [],
154
- refLines = [],
155
- numTicksX,
156
- numTicksY = 5,
157
- formatValue,
158
- formatYTick,
159
- tooltipLabel,
160
- renderExtraTooltipRows,
161
- markerShape = 'circle',
162
- plot,
163
- highlightedKey,
164
- } = props
154
+ // Memoized per (visible, data), NOT recomputed per render: the cursor is a shared store now, so
155
+ // every pointer frame re-renders every chart on the page. Without this, each frame walks the
156
+ // full series × data grid twice (lines, then markers) and allocates a fresh point array for
157
+ // each — the exact per-paint recompute the
158
+ // pre-rebuild kind memoized away.
159
+ const pointsBySeries = useMemo(() => {
160
+ const m = new Map<string, LinePt<T>[]>()
161
+ for (const s of visible) m.set(s.key, seriesPoints(s, data))
162
+ return m
163
+ }, [visible, data])
165
164
 
166
165
  // A series stays at full opacity when nothing is highlighted, when it IS the highlighted series,
167
- // or when it is a companion of the highlighted series (its `parent` matches).
168
- const dimOpacity = (s: ChartSeries<T>): number =>
169
- highlightedKey === null || s.key === highlightedKey || s.parent === highlightedKey ? 1 : 0.25
170
-
171
- const MARGIN = VX.margin
172
- const xMax = plot.width - MARGIN.left - MARGIN.right
173
- const yMax = plot.height - MARGIN.top - MARGIN.bottom
174
-
175
- const xScale = useMemo(
176
- () =>
177
- scalePoint<string>({
178
- // Full calendar — axis does not compress across nulls.
179
- domain: data.map(getX),
180
- range: [0, xMax],
181
- padding: 0.3,
182
- }),
183
- [data, xMax, getX],
166
+ // or when it is a companion of the highlighted series (its `parent` matches). `useCallback`
167
+ // (rather than a plain function) gives both a stable identity across renders where `highlighted`/
168
+ // `y2Scale` haven't changed, so `markerNodes` below can depend on THEM rather than the raw
169
+ // scale/highlighted values a closure would otherwise hide from the deps array.
170
+ const dimOpacity = useCallback(
171
+ (s: ChartSeries<T>): number =>
172
+ highlighted === null || s.key === highlighted || s.parent === highlighted ? 1 : 0.25,
173
+ [highlighted],
184
174
  )
185
175
 
186
- const yScale = useMemo(() => {
187
- if (yDomain === 'auto') {
188
- let dataMax = -Infinity
189
- let dataMin = Infinity
190
- for (const d of data) {
191
- for (const s of series) {
192
- const v = s.getValue(d)
193
- if (v === null || v === undefined || Number.isNaN(v)) continue
194
- if (v > dataMax) dataMax = v
195
- if (v < dataMin) dataMin = v
196
- }
197
- }
198
- const safeMax = Number.isFinite(dataMax) ? dataMax : 0
199
- const safeMin = Number.isFinite(dataMin) ? dataMin : 0
200
- const upper = Math.max(safeMax, yAutoMaxFloor ?? safeMax) * yAutoPad
201
- const lower = padAutoLower(Math.min(safeMin, yAutoMinCeil), yAutoPad)
202
- return scaleLinear<number>({ domain: [lower, upper], range: [yMax, 0], nice: true })
203
- }
204
- return scaleLinear<number>({ domain: yDomain, range: [yMax, 0] })
205
- }, [data, series, yDomain, yMax, yAutoPad, yAutoMaxFloor, yAutoMinCeil])
206
-
207
- const tooltipStyles = useTooltipStyles()
208
- const { tip, tooltipRef, syncedPoint, isDirectHover, handleMouse, handleLeave } = useHoverSync<T>(
209
- {
210
- data,
211
- chartId,
212
- getKey: getX,
213
- xScale,
214
- marginLeft: MARGIN.left,
215
- },
176
+ const scaleFor = useCallback(
177
+ (s: ChartSeries<T>) => (s.axis === 'right' && y2Scale !== null ? y2Scale : yScale),
178
+ [y2Scale, yScale],
216
179
  )
217
180
 
218
- const tickValues = useMemo(
181
+ // Memoized for the same reason as `pointsBySeries` above — the shared cursor re-renders every
182
+ // mounted chart on every pointer frame, and this walks every visible series' points calling the
183
+ // consumer's `getMarker` each time otherwise.
184
+ const markerNodes = useMemo<ReactNode[]>(
219
185
  () =>
220
- numTicksX ? smartTicksEvery(data.map(getX), numTicksX) : smartTicks(data.map(getX), xMax),
221
- [data, xMax, getX, numTicksX],
186
+ visible.flatMap((s) => {
187
+ const getMarker = s.getMarker
188
+ if (!getMarker) return [] as ReactNode[]
189
+ const scale = scaleFor(s)
190
+ const op = dimOpacity(s)
191
+ const markers: ReactNode[] = []
192
+ for (const p of pointsBySeries.get(s.key) ?? []) {
193
+ const m = getMarker(p.__d)
194
+ if (m === null) continue
195
+ const cx = xScale(getX(p.__d)) ?? 0
196
+ const cy = scale(p.__y)
197
+ const color = m.color ?? s.color
198
+ const r = m.r ?? (markerShape === 'star' ? STAR_R : VX.dotR)
199
+ markers.push(
200
+ markerShape === 'star' ? (
201
+ <path
202
+ key={`mk-${s.key}-${getX(p.__d)}`}
203
+ d={starPath(cx, cy, r)}
204
+ fill={color}
205
+ stroke={VX.dotStroke}
206
+ strokeWidth={1.5}
207
+ fillOpacity={op}
208
+ strokeOpacity={op}
209
+ />
210
+ ) : (
211
+ <circle
212
+ key={`mk-${s.key}-${getX(p.__d)}`}
213
+ cx={cx}
214
+ cy={cy}
215
+ r={r}
216
+ fill={color}
217
+ stroke={VX.dotStroke}
218
+ strokeWidth={2}
219
+ fillOpacity={op}
220
+ strokeOpacity={op}
221
+ />
222
+ ),
223
+ )
224
+ }
225
+ return markers
226
+ }),
227
+ [visible, pointsBySeries, xScale, getX, scaleFor, dimOpacity, markerShape],
222
228
  )
223
229
 
224
- // Per-series valid points, computed once per (data, series) change — not re-walked inside the
225
- // render map on every paint (parity with ZonedLine's single `valid` memo). Reused by the lines,
226
- // the per-point markers, and the synced dots.
227
- type LinePt = { __d: T; __y: number }
228
- const seriesPts = useMemo(() => {
229
- const out = new Map<string, LinePt[]>()
230
- for (const s of series) {
231
- const pts: LinePt[] = []
232
- for (const d of data) {
233
- const v = s.getValue(d)
234
- if (v !== null && v !== undefined && !Number.isNaN(v)) pts.push({ __d: d, __y: v })
235
- }
236
- out.set(s.key, pts)
237
- }
238
- return out
239
- }, [data, series])
240
-
241
- const tooltipLbl = tip ? (tooltipLabel?.(tip.data) ?? null) : null
242
-
243
230
  return (
244
231
  <>
245
- <svg width={plot.width} height={plot.height}>
246
- <Group left={MARGIN.left} top={MARGIN.top}>
247
- <GridRows scale={yScale} width={xMax} stroke={VX.grid} numTicks={numTicksY} />
248
-
249
- <ZoneRects zones={zones} width={xMax} leftScale={yScale} />
250
-
251
- {refLines.map((r, i) => (
252
- <line
253
- key={`ref-${i}`}
254
- x1={0}
255
- x2={xMax}
256
- y1={yScale(r.value)}
257
- y2={yScale(r.value)}
258
- stroke={r.color}
259
- strokeDasharray={r.dashed ? VX.dashArray : undefined}
260
- />
261
- ))}
262
-
263
- {series.map((s) => {
264
- const valid = seriesPts.get(s.key) ?? []
265
- if (valid.length === 0) return null
266
- return (
267
- <LinePath<LinePt>
268
- key={`line-${s.key}`}
269
- data={valid}
270
- x={(p) => xScale(getX(p.__d)) ?? 0}
271
- y={(p) => yScale(p.__y)}
272
- stroke={s.color}
273
- strokeWidth={s.strokeWidth ?? VX.lineWidth}
274
- strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
275
- strokeOpacity={dimOpacity(s)}
276
- curve={curveMonotoneX}
277
- />
278
- )
279
- })}
280
-
281
- {series.flatMap((s) => {
282
- const getMarker = s.getMarker
283
- if (!getMarker) return [] as ReactNode[]
284
- const op = dimOpacity(s)
285
- const markers: ReactNode[] = []
286
- for (const p of seriesPts.get(s.key) ?? []) {
287
- const m = getMarker(p.__d)
288
- if (m === null) continue
289
- const cx = xScale(getX(p.__d)) ?? 0
290
- const cy = yScale(p.__y)
291
- const color = m.color ?? s.color
292
- const r = m.r ?? (markerShape === 'star' ? STAR_R : VX.dotR)
293
- markers.push(
294
- markerShape === 'star' ? (
295
- <path
296
- key={`mk-${s.key}-${getX(p.__d)}`}
297
- d={starPath(cx, cy, r)}
298
- fill={color}
299
- stroke={VX.dotStroke}
300
- strokeWidth={1.5}
301
- fillOpacity={op}
302
- strokeOpacity={op}
303
- />
304
- ) : (
305
- <circle
306
- key={`mk-${s.key}-${getX(p.__d)}`}
307
- cx={cx}
308
- cy={cy}
309
- r={r}
310
- fill={color}
311
- stroke={VX.dotStroke}
312
- strokeWidth={2}
313
- fillOpacity={op}
314
- strokeOpacity={op}
315
- />
316
- ),
317
- )
318
- }
319
- return markers
320
- })}
321
-
322
- {syncedPoint &&
323
- (() => {
324
- const sx = xScale(getX(syncedPoint)) ?? 0
325
- return (
326
- <>
327
- <Crosshair x={sx} top={0} bottom={yMax} />
328
- {series.map((s) => {
329
- const v = s.getValue(syncedPoint)
330
- if (v === null || v === undefined || Number.isNaN(v)) return null
331
- return <SeriesDot key={`dot-${s.key}`} cx={sx} cy={yScale(v)} color={s.color} />
332
- })}
333
- </>
334
- )
335
- })()}
336
-
337
- <AxisLeftNumeric
338
- scale={yScale}
339
- numTicks={numTicksY}
340
- {...(formatYTick !== undefined && { tickFormat: formatYTick })}
232
+ {visible.map((s) => {
233
+ const valid = pointsBySeries.get(s.key) ?? []
234
+ if (valid.length === 0) return null
235
+ const scale = scaleFor(s)
236
+ return (
237
+ <LinePath<LinePt<T>>
238
+ key={`line-${s.key}`}
239
+ data={valid}
240
+ x={(p) => xScale(getX(p.__d)) ?? 0}
241
+ y={(p) => scale(p.__y)}
242
+ stroke={s.color}
243
+ strokeWidth={s.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
244
+ strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
245
+ strokeOpacity={dimOpacity(s) * (s.strokeOpacity ?? 1)}
246
+ curve={curveMonotoneX}
341
247
  />
342
- <AxisBottomDate top={yMax} scale={xScale} tickValues={tickValues} />
248
+ )
249
+ })}
343
250
 
344
- <HoverOverlay width={xMax} height={yMax} onMove={handleMouse} onLeave={handleLeave} />
345
- </Group>
346
- </svg>
347
- <ChartTooltip tip={isDirectHover ? tip : null} tooltipRef={tooltipRef} styles={tooltipStyles}>
348
- {tip && isDirectHover && (
349
- <>
350
- <TooltipHeader
351
- date={getX(tip.data)}
352
- {...(tooltipLbl !== null && { label: tooltipLbl.text, labelColor: tooltipLbl.color })}
353
- />
354
- <TooltipBody>
355
- {deriveTooltipRows(series, tip.data, formatValue).map((row) => (
356
- <TooltipRow
357
- key={row.key}
358
- color={row.color}
359
- label={row.label}
360
- value={row.value}
361
- shape={row.shape}
362
- dashed={row.dashed}
363
- {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
364
- />
365
- ))}
366
- {renderExtraTooltipRows?.(tip.data)}
367
- </TooltipBody>
368
- </>
369
- )}
370
- </ChartTooltip>
251
+ {markerNodes}
371
252
  </>
372
253
  )
373
254
  }