basalt-ui 2.3.0 → 2.4.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.
@@ -205,14 +205,14 @@ function assignDefined(into, from) {
205
205
  if (value !== void 0) into[key] = value;
206
206
  }
207
207
  }
208
- function resolveField(entry, search, record) {
208
+ function resolveField(entry, search, record, fallback) {
209
209
  const fromUrl = search === null ? null : entry.codec.fromSearch(search);
210
210
  if (fromUrl !== null) return fromUrl;
211
211
  if (entry.codec.lane.persist) {
212
212
  const stored = entry.codec.decode(record[entry.name]);
213
213
  if (stored !== null) return stored;
214
214
  }
215
- return entry.codec.fallback;
215
+ return fallback === void 0 ? entry.codec.fallback : fallback;
216
216
  }
217
217
  function createMemoryLane() {
218
218
  const values = /* @__PURE__ */ new Map();
@@ -232,9 +232,10 @@ function createMemoryLane() {
232
232
  getVersion: () => version,
233
233
  // `undefined` is the only "never written" marker the lane needs: no field kind has `undefined`
234
234
  // as a legal value, so it can never collide with a written one.
235
- resolve: (entry) => {
235
+ resolve: (entry, fallback) => {
236
236
  const stored = values.get(entry.name);
237
- return stored === void 0 ? entry.codec.fallback : stored;
237
+ if (stored !== void 0) return stored;
238
+ return fallback === void 0 ? entry.codec.fallback : fallback;
238
239
  },
239
240
  write: (name, value) => {
240
241
  values.set(name, value);
@@ -342,9 +343,12 @@ function createStoreCore(o) {
342
343
  },
343
344
  readRecord,
344
345
  memoryUse(entry) {
345
- return () => {
346
+ return (read) => {
346
347
  useSyncExternalStore(memory.subscribe, memory.getVersion, memory.getVersion);
347
- return [memory.resolve(entry), (next) => memory.write(entry.name, next)];
348
+ return [
349
+ memory.resolve(entry, read?.fallback),
350
+ (next) => memory.write(entry.name, next)
351
+ ];
348
352
  };
349
353
  },
350
354
  readMemoryValue: (entry) => memory.resolve(entry),
@@ -374,10 +378,12 @@ function createStoreCore(o) {
374
378
  }
375
379
  function createLocalStore(o) {
376
380
  const core = createStoreCore({ key: o.key, fields: o.fields, version: o.version });
377
- const persistedUse = (entry) => () => {
381
+ const persistedUse = (entry) => (read) => {
378
382
  const [record, setRecord] = core.usePersistedRecord();
379
383
  return [
380
- core.resolve(entry, null, record),
384
+ // The call-site fallback is applied here, in render, after the store read — never inside
385
+ // the snapshot — so React's out-of-render snapshot reads cannot disagree with it.
386
+ core.resolve(entry, null, record, read?.fallback),
381
387
  (next) => core.writeField(setRecord, entry.name, next)
382
388
  ];
383
389
  };
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v2.3.0
1
+ # basalt-ui v2.4.0
2
2
  # Opinionated framework for Mantine-based React apps — theme, app shell, TanStack Charts, toolchain presets
3
3
  #
4
4
  # Entry points: use the exact import specifier below; do not guess.
@@ -13,7 +13,7 @@
13
13
  # A subpath's `Install:` line is its required peers; `OptionalPeers:` are import()-only, install on use.
14
14
 
15
15
  Package: basalt-ui
16
- Version: 2.3.0
16
+ Version: 2.4.0
17
17
 
18
18
  ## basalt-ui
19
19
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, TanStack Charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -9,7 +9,7 @@
9
9
  import { useCallback, useId, useMemo, useState } from 'react'
10
10
  import type { CSSProperties, ReactElement, ReactNode } from 'react'
11
11
  import { useInChartCard } from './card-context'
12
- import { compileCartesian } from './compile'
12
+ import { compileCartesian, refLegendSeries } from './compile'
13
13
  import type { CartesianDefinition } from './compile'
14
14
  import { ChartEngine, idPrefixOf } from './host'
15
15
  import { ChartLegend, legendEntries } from './legend'
@@ -170,9 +170,14 @@ export function CartesianRung<T, X extends ChartX>(
170
170
  (ctx: ShellContext) => compileCartesian({ ...stable, ...ctx }),
171
171
  [stable],
172
172
  )
173
+ // Reference lines that name themselves join the legend after the series (role `reference`).
174
+ const legendSeries = useMemo(
175
+ () => [...stable.series, ...refLegendSeries<T>(stable.refLines)],
176
+ [stable.series, stable.refLines],
177
+ )
173
178
  return (
174
179
  <ChartShell
175
- series={series}
180
+ series={legendSeries}
176
181
  compile={compile}
177
182
  ariaLabel={ariaLabel}
178
183
  {...(legend !== undefined && { legend })}
@@ -14,7 +14,7 @@ import { useCallback, useMemo } from 'react'
14
14
  import type { ReactElement } from 'react'
15
15
  import { VX, alpha } from '../tokens'
16
16
  import { resolveContainerClass } from '../tokens/size-classes'
17
- import { defaultFormatX, defaultNumberFormat, visibleSeries } from './compile'
17
+ import { defaultFormatX, defaultNumberFormat, engineRow, seriesRow, visibleSeries } from './compile'
18
18
  import { barMark, emphasis, lineMark, markersOf, thinFill } from './marks'
19
19
  import { PanelRung, paneX, paneY, rowsOf } from './panel'
20
20
  import type { PaneBuilder, PanelInput, PanelPlan } from './panel'
@@ -61,8 +61,10 @@ export type DualPanelBar<T> = {
61
61
  positive: string
62
62
  /** The fill below zero. */
63
63
  negative: string
64
- /** Tick and tooltip formatter. */
64
+ /** Tick formatter, and the tooltip's too unless `tooltipFormat` is given. */
65
65
  format: (v: number) => string
66
+ /** The tooltip row's formatter. Default: `format`. */
67
+ tooltipFormat?: ((v: number) => string) | undefined
66
68
  /** The tooltip row's value, for a unit or a decimal `format` cannot carry. */
67
69
  formatValue?: ((v: number, d: T) => string) | undefined
68
70
  /** Default `'auto'`: a symmetric `[-maxAbs, maxAbs]` from the data, rounded to nice ticks. A tuple
@@ -95,6 +97,8 @@ export type DualPanelProps<T, X extends Date | number = Date | number> = Pick<
95
97
  | {
96
98
  domain?: readonly [number, number] | 'auto' | undefined
97
99
  format?: ((v: number) => string) | undefined
100
+ /** The lines' tooltip formatter. Default: `format`. */
101
+ tooltipFormat?: ((v: number) => string) | undefined
98
102
  }
99
103
  | undefined
100
104
  /**
@@ -157,6 +161,8 @@ export function DualPanel<T, X extends Date | number = Date | number>(
157
161
  )
158
162
  const all = useMemo(() => [...lines, barSeries], [lines, barSeries])
159
163
  const topFormat = top?.format ?? defaultNumberFormat
164
+ const topTooltipFormat = top?.tooltipFormat ?? topFormat
165
+ const barTooltipFormat = bar.tooltipFormat ?? bar.format
160
166
 
161
167
  const plan = useCallback(
162
168
  ({ hidden, highlighted }: PanelInput): PanelPlan<T> => {
@@ -307,26 +313,42 @@ export function DualPanel<T, X extends Date | number = Date | number>(
307
313
  for (const s of visible) {
308
314
  const v = s.getValue(d)
309
315
  if (s.tooltip === false || v === null || !Number.isFinite(v)) continue
310
- out.push({
311
- label: s.note === undefined ? s.label : `${s.label} · ${s.note}`,
312
- value: s.formatValue ? s.formatValue(v, d) : topFormat(v),
313
- color: s.color,
314
- ...(highlighted === s.key && { active: true }),
315
- })
316
+ out.push(
317
+ seriesRow(
318
+ s,
319
+ s.formatValue ? s.formatValue(v, d) : topTooltipFormat(v),
320
+ highlighted === s.key,
321
+ ),
322
+ )
316
323
  }
317
324
  const v = barOn ? bar.getValue(d) : null
318
325
  if (v !== null && Number.isFinite(v))
319
- out.push({
320
- label: bar.label,
321
- value: bar.formatValue ? bar.formatValue(v, d) : bar.format(v),
322
- color: v >= 0 ? bar.positive : bar.negative,
323
- ...(highlighted === BAR_KEY && { active: true }),
324
- })
326
+ out.push(
327
+ engineRow({
328
+ label: bar.label,
329
+ value: bar.formatValue ? bar.formatValue(v, d) : barTooltipFormat(v),
330
+ color: v >= 0 ? bar.positive : bar.negative,
331
+ active: highlighted === BAR_KEY,
332
+ }),
333
+ )
325
334
  return out
326
335
  },
327
336
  }
328
337
  },
329
- [data, x, lines, barSeries, top, fillBetween, bar, topFraction, topFormat, formatX],
338
+ [
339
+ data,
340
+ x,
341
+ lines,
342
+ barSeries,
343
+ top,
344
+ fillBetween,
345
+ bar,
346
+ topFraction,
347
+ topFormat,
348
+ topTooltipFormat,
349
+ barTooltipFormat,
350
+ formatX,
351
+ ],
330
352
  )
331
353
 
332
354
  return <PanelRung {...rung} series={all} plan={plan} />
@@ -18,7 +18,7 @@ import type { CSSProperties, ReactElement } from 'react'
18
18
  import type { BasaltProps } from '../common/props'
19
19
  import { alpha, VX } from '../tokens'
20
20
  import { resolveContainerClass } from '../tokens/size-classes'
21
- import { strideTicks } from './compile'
21
+ import { engineRow, strideTicks } from './compile'
22
22
  import type { CartesianDefinition } from './compile'
23
23
  import { ChartEngine, idPrefixOf } from './host'
24
24
  import { useCoarsePointer } from '../common/use-media-query'
@@ -244,7 +244,7 @@ export function Heatmap<T>(props: HeatmapProps<T>): ReactElement {
244
244
  ...(point.color && { color: point.color }),
245
245
  },
246
246
  ]
247
- return { title: header(c), rows: [...value, ...extra] }
247
+ return { title: header(c), rows: [...value, ...extra].map(engineRow) }
248
248
  },
249
249
  }
250
250
  : false,
@@ -17,7 +17,7 @@ import { VX } from '../tokens'
17
17
  import { CartesianRung } from './CartesianChart'
18
18
  import { areaGradientId } from './compile'
19
19
  import { chartCurve } from './curves'
20
- import { lineMark, markersOf, yScaleOf } from './marks'
20
+ import { drawnRows, lineMark, markersOf, yScaleOf } from './marks'
21
21
  import { rowX } from './types'
22
22
  import type {
23
23
  CartesianChartProps,
@@ -120,7 +120,7 @@ export function Lines<T, X extends ChartX = ChartX>(props: LinesProps<T, X>): Re
120
120
  if (!s.area) continue
121
121
  out.push(
122
122
  decorative(
123
- areaY(plot.rows(s.key), {
123
+ areaY(drawnRows(plot, s), {
124
124
  id: `${s.key}-area`,
125
125
  x: rowX,
126
126
  y2: 'y',
@@ -21,7 +21,7 @@ import { useCallback, useMemo } from 'react'
21
21
  import type { ReactElement } from 'react'
22
22
  import { VX, alpha } from '../tokens'
23
23
  import { resolveContainerClass } from '../tokens/size-classes'
24
- import { defaultFormatX, defaultNumberFormat, visibleSeries } from './compile'
24
+ import { defaultFormatX, defaultNumberFormat, seriesRow, visibleSeries } from './compile'
25
25
  import { barMark, emphasis } from './marks'
26
26
  import { PanelRung, paneX, paneY, rowsOf } from './panel'
27
27
  import type { PaneBuilder, PanelInput, PanelPlan } from './panel'
@@ -277,12 +277,13 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
277
277
  // Each pane's row falls back to ITS OWN formatter: the panes are in different units.
278
278
  const fallback =
279
279
  s.key === up.key ? up.format : s.key === down.key ? down.format : defaultNumberFormat
280
- out.push({
281
- label: s.note === undefined ? s.label : `${s.label} · ${s.note}`,
282
- value: s.formatValue ? s.formatValue(v, d) : fallback(v),
283
- color: s.color,
284
- ...(highlighted === s.key && { active: true }),
285
- })
280
+ out.push(
281
+ seriesRow(
282
+ s,
283
+ s.formatValue ? s.formatValue(v, d) : fallback(v),
284
+ highlighted === s.key,
285
+ ),
286
+ )
286
287
  }
287
288
  return out
288
289
  },
@@ -8,7 +8,7 @@
8
8
  import { crosshair } from '@tanstack/charts/crosshair'
9
9
  import { decorative } from '@tanstack/charts/mark/decorative'
10
10
  import { rect } from '@tanstack/charts/rect'
11
- import { ruleY } from '@tanstack/charts/rule'
11
+ import { ruleX, ruleY } from '@tanstack/charts/rule'
12
12
  import { scaleLinear } from '@tanstack/charts/scales/linear'
13
13
  import { scaleUtc } from 'd3-scale'
14
14
  import { tooltip as tooltipHost } from '@tanstack/charts/tooltip'
@@ -27,6 +27,7 @@ import { fmtCompact } from '../format/number'
27
27
  import { VX } from '../tokens'
28
28
  import type { ContainerClass } from '../tokens/size-classes'
29
29
  import { cursorBinding, sourceOnlyFocus } from './cursor'
30
+ import { cursorDots } from './marks'
30
31
  import { resolveContainerClass } from '../tokens/size-classes'
31
32
  import { resolveYDomain, xScaleFor, xZoneFractions, yScaleFor } from './scales'
32
33
  import type { XScaleModel } from './scales'
@@ -39,6 +40,8 @@ import type {
39
40
  ChartTooltipRow,
40
41
  ChartX,
41
42
  PlotRow,
43
+ RefLine,
44
+ ZoneEdge,
42
45
  ZoneSpec,
43
46
  } from './types'
44
47
 
@@ -136,6 +139,67 @@ export function defaultFormatX(v: ChartValue): string {
136
139
  const clampTo = (v: number, [lo, hi]: readonly [number, number]): number =>
137
140
  Math.min(Math.max(v, Math.min(lo, hi)), Math.max(lo, hi))
138
141
 
142
+ /**
143
+ * A tooltip swatch is the engine's `background` shorthand, so a dashed one is a gradient: a 2px
144
+ * dashed line across the swatch's middle, in the row color.
145
+ */
146
+ export const dashedSwatch = (color: string): string =>
147
+ `repeating-linear-gradient(90deg, ${color} 0 2px, transparent 2px 4px) center / 100% 2px no-repeat`
148
+
149
+ /** A basalt tooltip row as the engine's: the swatch shape folded into the row color. */
150
+ export function engineRow(
151
+ row: ChartTooltipRow & { active?: boolean | undefined },
152
+ ): EngineTooltipRow {
153
+ const { label, value, color, swatch, active } = row
154
+ return {
155
+ label,
156
+ value,
157
+ ...(color !== undefined && { color: swatch === 'dashed' ? dashedSwatch(color) : color }),
158
+ ...(active && { active }),
159
+ }
160
+ }
161
+
162
+ /** A series row of a tooltip: the label with its note, the swatch following the series' dash. */
163
+ export function seriesRow(
164
+ s: Pick<ChartSeries<never>, 'label' | 'note' | 'color' | 'dash'>,
165
+ value: string,
166
+ active: boolean,
167
+ ): EngineTooltipRow {
168
+ return engineRow({
169
+ label: s.note === undefined ? s.label : `${s.label} · ${s.note}`,
170
+ value,
171
+ color: s.color,
172
+ swatch: s.dash === 'dashed' ? 'dashed' : 'solid',
173
+ active,
174
+ })
175
+ }
176
+
177
+ /** Keys the compiler reserves for the legend entries it derives from `refLines`. */
178
+ const REF_KEY_PREFIX = '__ref-'
179
+
180
+ /** The legend key of a reference line that names itself (`RefLine.legend`). */
181
+ const refLegendKey = (index: number): string => `${REF_KEY_PREFIX}${index}`
182
+
183
+ /** Legend-only entries for the reference lines that name themselves, in line order. */
184
+ export function refLegendSeries<T>(refLines: readonly RefLine[] | undefined): ChartSeries<T>[] {
185
+ return (refLines ?? []).flatMap((r, i) =>
186
+ r.legend === undefined
187
+ ? []
188
+ : [
189
+ {
190
+ key: refLegendKey(i),
191
+ label: r.legend.label,
192
+ color: r.color,
193
+ role: 'reference' as const,
194
+ tooltip: false,
195
+ getValue: () => null,
196
+ ...(r.legend.mark !== undefined && { mark: r.legend.mark }),
197
+ ...(r.dashed && { dash: 'dashed' as const }),
198
+ },
199
+ ],
200
+ )
201
+ }
202
+
139
203
  /** The visible series: not toggled off, and not a companion whose parent is. */
140
204
  export function visibleSeries<T>(
141
205
  series: readonly ChartSeries<T>[],
@@ -435,32 +499,93 @@ function resolveAxes<T, X extends ChartX>(
435
499
  }
436
500
  }
437
501
 
438
- /** Fixed paint under the kind's marks: zones, then x-zones. */
502
+ /** The sides a `ZoneEdge` names, in a fixed order: the lower bound first. */
503
+ const edgeSides = <A extends string, B extends string>(
504
+ edge: ZoneEdge<A | B> | undefined,
505
+ lower: A,
506
+ upper: B,
507
+ ): { lower: boolean; upper: boolean } => ({
508
+ lower: edge !== undefined && (edge.side === lower || edge.side === 'both'),
509
+ upper: edge !== undefined && (edge.side === upper || edge.side === 'both'),
510
+ })
511
+
512
+ const edgePaint = (edge: ZoneEdge<string>) => ({
513
+ stroke: edge.color,
514
+ strokeOpacity: 1,
515
+ strokeWidth: edge.width ?? 1,
516
+ ...(edge.dashed && { strokeDasharray: DASH }),
517
+ })
518
+
519
+ /**
520
+ * An edge rule at `position` when it lies strictly inside `(lo, hi)`: a bound at or past the plot
521
+ * edge (infinite, outside the domain, exactly on it) has no rule, for y- and x-zones alike.
522
+ */
523
+ const edgeRule = (
524
+ position: number | null,
525
+ [lo, hi]: readonly [number, number],
526
+ make: (at: number) => ChartMarks[number],
527
+ ): ChartMarks => (position !== null && position > lo && position < hi ? [make(position)] : [])
528
+
529
+ /** Fixed paint under the kind's marks: zones, then x-zones, each with its edge rules. */
439
530
  function underlayMarks<T, X extends ChartX>(
440
531
  input: CartesianInput<T, X>,
441
532
  model: XScaleModel<X>,
442
533
  axes: Axes<T>,
443
534
  ): ChartMarks {
444
535
  const fractionRows = [{ f0: 0, f1: 1 }]
445
- const zones = (input.zones ?? []).map((z, i) => {
536
+ const zones = (input.zones ?? []).flatMap((z, i) => {
446
537
  const domain = axes.axisOf(z.axisSide).domain
447
- return decorative(
448
- rect(fractionRows, {
449
- id: `zone-${i}`,
450
- x1: 'f0',
451
- x2: 'f1',
452
- y1: () => clampTo(z.from, domain),
453
- y2: () => clampTo(z.to, domain),
454
- xScale: 'zx',
455
- yScale: axes.scaleId(z.axisSide),
456
- fill: z.fill,
457
- inset: 0,
458
- }),
459
- )
538
+ const [lo, hi] = [Math.min(...domain), Math.max(...domain)]
539
+ const rule = (value: number, name: string) =>
540
+ edgeRule(value, [lo, hi], (at) =>
541
+ decorative(
542
+ ruleY([at], {
543
+ id: `zone-${i}-${name}`,
544
+ yScale: axes.scaleId(z.axisSide),
545
+ ...(z.edge && edgePaint(z.edge)),
546
+ }),
547
+ ),
548
+ )
549
+ const sides = edgeSides(z.edge, 'bottom', 'top')
550
+ return [
551
+ decorative(
552
+ rect(fractionRows, {
553
+ id: `zone-${i}`,
554
+ x1: 'f0',
555
+ x2: 'f1',
556
+ y1: () => clampTo(z.from, domain),
557
+ y2: () => clampTo(z.to, domain),
558
+ xScale: 'zx',
559
+ yScale: axes.scaleId(z.axisSide),
560
+ fill: z.fill,
561
+ inset: 0,
562
+ }),
563
+ ),
564
+ ...(sides.lower ? rule(Math.min(z.from, z.to), 'bottom') : []),
565
+ ...(sides.upper ? rule(Math.max(z.from, z.to), 'top') : []),
566
+ ]
460
567
  })
461
568
  const xZones = (input.xZones ?? []).flatMap((z, i) => {
462
569
  const f = xZoneFractions(z, model)
463
570
  if (!f) return []
571
+ const sides = edgeSides(z.edge, 'start', 'end')
572
+ // The bound's own plot fraction (widened like the band), unclamped: `f` is clamped to the plot.
573
+ const widen = z.align === 'edge' ? model.halfStep : 0
574
+ const bound = (value: X | undefined, sign: 1 | -1): number | null => {
575
+ const fraction = value === undefined ? null : model.fraction(value)
576
+ return fraction === null ? null : fraction + sign * widen
577
+ }
578
+ const rule = (value: X | undefined, sign: 1 | -1, name: string) =>
579
+ edgeRule(bound(value, sign), [0, 1], (at) =>
580
+ decorative(
581
+ ruleX([at], {
582
+ id: `xzone-${i}-${name}`,
583
+ x: (v: number) => v,
584
+ xScale: 'zx',
585
+ ...(z.edge && edgePaint(z.edge)),
586
+ }),
587
+ ),
588
+ )
464
589
  return [
465
590
  decorative(
466
591
  rect([{ f0: f[0], f1: f[1] }], {
@@ -474,6 +599,9 @@ function underlayMarks<T, X extends ChartX>(
474
599
  inset: 0,
475
600
  }),
476
601
  ),
602
+ // An omitted bound is the plot edge: nothing to draw a rule on.
603
+ ...(sides.lower ? rule(z.from, -1, 'start') : []),
604
+ ...(sides.upper ? rule(z.to, 1, 'end') : []),
477
605
  ]
478
606
  })
479
607
  return [...zones, ...xZones]
@@ -484,15 +612,20 @@ const refLineMarks = <T, X extends ChartX>(
484
612
  input: CartesianInput<T, X>,
485
613
  axes: Axes<T>,
486
614
  ): ChartMarks =>
487
- (input.refLines ?? []).map((r, i) =>
488
- ruleY([r.value], {
489
- id: `ref-${i}`,
490
- stroke: r.color,
491
- strokeOpacity: 1,
492
- strokeWidth: 1,
493
- yScale: axes.scaleId(r.axis),
494
- ...(r.dashed && { strokeDasharray: DASH }),
495
- }),
615
+ (input.refLines ?? []).flatMap((r, i) =>
616
+ // A line that names itself in the legend can be toggled off there.
617
+ input.hidden?.has(refLegendKey(i))
618
+ ? []
619
+ : [
620
+ ruleY([r.value], {
621
+ id: `ref-${i}`,
622
+ stroke: r.color,
623
+ strokeOpacity: 1,
624
+ strokeWidth: 1,
625
+ yScale: axes.scaleId(r.axis),
626
+ ...(r.dashed && { strokeDasharray: DASH }),
627
+ }),
628
+ ],
496
629
  )
497
630
 
498
631
  function buildScales<T, X extends ChartX>(opts: {
@@ -568,6 +701,25 @@ function rowsOf<T, X extends ChartX>(
568
701
  }
569
702
  }
570
703
 
704
+ /** The cursor dots of the visible series that opt in with `cursorValue`. */
705
+ function cursorDotMarks<T, X extends ChartX>(
706
+ input: CartesianInput<T, X>,
707
+ visible: readonly ChartSeries<T>[],
708
+ axes: Axes<T>,
709
+ ): ChartMarks {
710
+ return visible.flatMap((s) => {
711
+ const { cursorValue } = s
712
+ if (!cursorValue) return []
713
+ const rows = input.data.flatMap((d): PlotRow<T, X>[] => {
714
+ const y = cursorValue(d)
715
+ return y === null || !Number.isFinite(y)
716
+ ? []
717
+ : [{ x: input.x(d), y, series: s.key, datum: d }]
718
+ })
719
+ return [cursorDots(s, rows, axes.onRight(s) ? 'y2' : undefined)]
720
+ })
721
+ }
722
+
571
723
  function tooltipSpec<T, X extends ChartX>(opts: {
572
724
  input: CartesianInput<T, X>
573
725
  axes: Axes<T>
@@ -599,25 +751,40 @@ function tooltipSpec<T, X extends ChartX>(opts: {
599
751
  for (const s of rowSeries) {
600
752
  const v = s.getValue(d)
601
753
  if (v === null || !Number.isFinite(v)) continue
602
- rows.push({
603
- label: s.note === undefined ? s.label : `${s.label} · ${s.note}`,
604
- value: s.formatValue
605
- ? s.formatValue(v, d)
606
- : axes.axisOf(axes.onRight(s) ? 'right' : 'left').format(v),
607
- color: s.color,
608
- ...(input.highlighted === s.key && { active: true }),
609
- })
754
+ rows.push(
755
+ seriesRow(
756
+ s,
757
+ s.formatValue
758
+ ? s.formatValue(v, d)
759
+ : axes.axisOf(axes.onRight(s) ? 'right' : 'left').format(v),
760
+ input.highlighted === s.key,
761
+ ),
762
+ )
610
763
  }
611
764
  const extra: readonly ChartTooltipRow[] =
612
765
  (input.tooltip || undefined)?.rows?.(d, { visible, hidden }) ?? []
613
- return { title: tooltipHeader(input, formatX, x, d), rows: [...rows, ...extra] }
766
+ return {
767
+ title: tooltipHeader(input, formatX, x, d),
768
+ rows: [...rows, ...extra.map(engineRow)],
769
+ }
614
770
  },
615
771
  }
616
772
  }
617
773
 
774
+ /** A consumer key in the legend's reserved `__ref-` namespace would collide with a reference line. */
775
+ function guardReservedKeys<T>(series: readonly ChartSeries<T>[]): void {
776
+ if (process.env.NODE_ENV === 'production') return
777
+ const clash = series.find((s) => s.key.startsWith(REF_KEY_PREFIX))
778
+ if (clash)
779
+ throw new Error(
780
+ `[basalt] CartesianChart: the series key "${clash.key}" is reserved (\`${REF_KEY_PREFIX}\` prefix names reference-line legend entries)`,
781
+ )
782
+ }
783
+
618
784
  export function compileCartesian<T, X extends ChartX>(
619
785
  input: CartesianInput<T, X>,
620
786
  ): CartesianDefinition {
787
+ guardReservedKeys(input.series)
621
788
  const visible = visibleSeries(input.series, input.hidden ?? new Set<string>())
622
789
  const visibleKeys = new Set(visible.map((s) => s.key))
623
790
  const keys = new Set(input.series.map((s) => s.key))
@@ -625,6 +792,7 @@ export function compileCartesian<T, X extends ChartX>(
625
792
  const axes = resolveAxes(input, visible)
626
793
  const under = underlayMarks(input, model, axes)
627
794
  const over = refLineMarks(input, axes)
795
+ const dots = cursorDotMarks(input, visible, axes)
628
796
  const rows = rowsOf(input, visibleKeys)
629
797
  const formatX = (v: ChartValue): string =>
630
798
  input.formatX ? input.formatX(v as X) : defaultFormatX(v)
@@ -647,7 +815,7 @@ export function compileCartesian<T, X extends ChartX>(
647
815
  y2Domain: axes.right?.domain,
648
816
  })
649
817
  return {
650
- marks: [...under, ...kindMarks, ...over, crosshair({ x: CROSSHAIR, y: false })],
818
+ marks: [...under, ...kindMarks, ...dots, ...over, crosshair({ x: CROSSHAIR, y: false })],
651
819
  scales: buildScales({ input, model, axes, formatX, cls, zoneScale: under.length > 0 }),
652
820
  theme: CHART_THEME,
653
821
  ...(gradients.length > 0 && { gradients }),
@@ -30,12 +30,21 @@ export function emphasis<T>(
30
30
  return highlighted === null || highlighted === s.key || highlighted === s.parent ? 1 : DIMMED
31
31
  }
32
32
 
33
+ /** The rows a series' line and area are drawn through: all of them, or only the non-null ones. */
34
+ export function drawnRows<T, X extends ChartX>(
35
+ plot: PlotContext<T, X>,
36
+ s: Pick<ChartSeries<T>, 'key' | 'gaps'>,
37
+ ): PlotRow<T, X>[] {
38
+ const rows = plot.rows(s.key)
39
+ return s.gaps === 'connect' ? rows.filter((r) => r.y !== null) : rows
40
+ }
41
+
33
42
  export function lineMark<T, X extends ChartX>(
34
43
  plot: PlotContext<T, X>,
35
44
  s: ChartSeries<T>,
36
45
  ): ChartMarks[number] {
37
46
  const yScale = yScaleOf(plot, s)
38
- return lineY(plot.rows(s.key), {
47
+ return lineY(drawnRows(plot, s), {
39
48
  id: s.key,
40
49
  x: rowX,
41
50
  y: 'y',
@@ -96,6 +105,31 @@ export function markersOf<T, X extends ChartX>(
96
105
  )
97
106
  }
98
107
 
108
+ /**
109
+ * The cursor dot of a series the consumer draws itself (`ChartSeries.cursorValue`): a dot per datum,
110
+ * invisible until the cursor is on that datum's x. It is NOT decorative, so the engine counts it as
111
+ * a focus point and the shared cursor, the focus group and the tooltip reach the series.
112
+ */
113
+ export function cursorDots<T, X extends ChartX>(
114
+ s: ChartSeries<T>,
115
+ rows: readonly PlotRow<T, X>[],
116
+ yScale: 'y2' | undefined,
117
+ ): ChartMarks[number] {
118
+ return dot(rows, {
119
+ id: `${s.key}-cursor`,
120
+ x: rowX,
121
+ y: 'y',
122
+ r: VX.dotR,
123
+ fill: s.color,
124
+ fillOpacity: 0,
125
+ stroke: VX.surface.panel,
126
+ strokeWidth: 1.5,
127
+ strokeOpacity: 0,
128
+ states: [{ when: { focus: 'x' }, style: { fillOpacity: 1, strokeOpacity: 1 } }],
129
+ ...(yScale !== undefined && { yScale }),
130
+ })
131
+ }
132
+
99
133
  /**
100
134
  * A single-series `barY`. The engine stacks every `barY` that has no explicit extent, and a row with
101
135
  * no `z`/`color` channel is its own series: an N-by-N matrix (a 5k-row `DualPanel` took 10 s). So