basalt-ui 2.2.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/MIGRATING.md +78 -12
  3. package/agent/rules/basalt-state.md +1 -1
  4. package/configs/lefthook.yml +4 -1
  5. package/dist/charts/BandStrip.d.ts +7 -7
  6. package/dist/charts/BandStrip.d.ts.map +1 -1
  7. package/dist/charts/Bars.d.ts +4 -4
  8. package/dist/charts/Bars.d.ts.map +1 -1
  9. package/dist/charts/CartesianChart.d.ts.map +1 -1
  10. package/dist/charts/CartesianChart.js +6 -2
  11. package/dist/charts/ChartCard.d.ts +10 -10
  12. package/dist/charts/ChartCard.d.ts.map +1 -1
  13. package/dist/charts/ChartCard.js +2 -1
  14. package/dist/charts/Donut.d.ts +5 -5
  15. package/dist/charts/Donut.d.ts.map +1 -1
  16. package/dist/charts/DualPanel.d.ts +14 -10
  17. package/dist/charts/DualPanel.d.ts.map +1 -1
  18. package/dist/charts/DualPanel.js +32 -14
  19. package/dist/charts/Heatmap.d.ts +15 -15
  20. package/dist/charts/Heatmap.d.ts.map +1 -1
  21. package/dist/charts/Heatmap.js +2 -2
  22. package/dist/charts/Lines.js +2 -2
  23. package/dist/charts/MirroredBars.d.ts +9 -9
  24. package/dist/charts/MirroredBars.d.ts.map +1 -1
  25. package/dist/charts/MirroredBars.js +8 -7
  26. package/dist/charts/Sparkline.d.ts +6 -6
  27. package/dist/charts/Sparkline.d.ts.map +1 -1
  28. package/dist/charts/StackedArea.d.ts +2 -2
  29. package/dist/charts/StackedArea.d.ts.map +1 -1
  30. package/dist/charts/compile.d.ts +25 -2
  31. package/dist/charts/compile.d.ts.map +1 -1
  32. package/dist/charts/compile.js +204 -34
  33. package/dist/charts/index.d.ts +1 -0
  34. package/dist/charts/index.d.ts.map +1 -1
  35. package/dist/charts/index.js +3 -1
  36. package/dist/charts/marks.d.ts +8 -0
  37. package/dist/charts/marks.d.ts.map +1 -1
  38. package/dist/charts/marks.js +22 -1
  39. package/dist/charts/panel.js +2 -2
  40. package/dist/charts/types.d.ts +101 -61
  41. package/dist/charts/types.d.ts.map +1 -1
  42. package/dist/state/fields.d.ts +19 -3
  43. package/dist/state/fields.d.ts.map +1 -1
  44. package/dist/state/fields.js +14 -8
  45. package/llms.txt +2 -2
  46. package/package.json +1 -1
  47. package/src/charts/BandStrip.tsx +12 -10
  48. package/src/charts/Bars.tsx +4 -4
  49. package/src/charts/CartesianChart.tsx +7 -2
  50. package/src/charts/ChartCard.tsx +17 -11
  51. package/src/charts/Donut.tsx +5 -5
  52. package/src/charts/DualPanel.tsx +50 -21
  53. package/src/charts/Heatmap.tsx +14 -14
  54. package/src/charts/Lines.tsx +2 -2
  55. package/src/charts/MirroredBars.tsx +16 -15
  56. package/src/charts/Sparkline.tsx +6 -6
  57. package/src/charts/StackedArea.tsx +2 -2
  58. package/src/charts/compile.ts +285 -36
  59. package/src/charts/index.ts +1 -0
  60. package/src/charts/marks.ts +35 -1
  61. package/src/charts/panel.tsx +2 -2
  62. package/src/charts/types.ts +105 -57
  63. package/src/state/fields.ts +55 -19
@@ -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,9 @@ import type {
39
40
  ChartTooltipRow,
40
41
  ChartX,
41
42
  PlotRow,
43
+ RefLine,
44
+ ZoneEdge,
45
+ ZoneSpec,
42
46
  } from './types'
43
47
 
44
48
  /**
@@ -135,6 +139,67 @@ export function defaultFormatX(v: ChartValue): string {
135
139
  const clampTo = (v: number, [lo, hi]: readonly [number, number]): number =>
136
140
  Math.min(Math.max(v, Math.min(lo, hi)), Math.max(lo, hi))
137
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
+
138
203
  /** The visible series: not toggled off, and not a companion whose parent is. */
139
204
  export function visibleSeries<T>(
140
205
  series: readonly ChartSeries<T>[],
@@ -249,6 +314,86 @@ export function areaGradient(index: number, color: string): ChartGradient {
249
314
  }
250
315
  }
251
316
 
317
+ const areaTint = (color: string): string =>
318
+ `color-mix(in srgb, ${color} var(--vx-area-top), transparent)`
319
+
320
+ /**
321
+ * A series area filled by value zone: a vertical gradient whose stops sit at the zone boundaries.
322
+ * The engine writes `<linearGradient>` in the default `objectBoundingBox` units, so offsets are
323
+ * fractions of the AREA's own box, not of the plot: from the larger of the series' maximum and 0 down
324
+ * to the smaller of its minimum and 0, the baseline the area closes to even when the y domain
325
+ * excludes 0. A boundary at value `v` sits at `(ceiling - v) / (ceiling - floor)`. Hard edges:
326
+ * neighbouring zones meet at one offset; overlapping zones never run backwards. `null` when no zone
327
+ * reaches the area (the caller keeps the plain series-colour fade). Linear axes only.
328
+ */
329
+ export function zoneAreaGradient(
330
+ index: number,
331
+ zones: readonly ZoneSpec[],
332
+ values: readonly (number | null)[],
333
+ ): ChartGradient | null {
334
+ let floor = 0
335
+ let ceiling = 0
336
+ for (const v of values) {
337
+ if (v === null || !Number.isFinite(v)) continue
338
+ if (v < floor) floor = v
339
+ if (v > ceiling) ceiling = v
340
+ }
341
+ const span = ceiling - floor
342
+ if (span <= 0) return null
343
+ const offsetOf = (v: number): number => (ceiling - Math.min(ceiling, Math.max(floor, v))) / span
344
+ const reaching = zones
345
+ .filter((z) => !Number.isNaN(z.from) && !Number.isNaN(z.to) && z.to > floor && z.from < ceiling)
346
+ .toSorted((p, q) => q.to - p.to)
347
+ let previous = 0
348
+ const stops = reaching.flatMap((z) =>
349
+ [offsetOf(z.to), offsetOf(z.from)].map((raw) => {
350
+ previous = Math.max(previous, raw)
351
+ return { offset: previous, color: areaTint(z.fill) }
352
+ }),
353
+ )
354
+ if (stops.length === 0) return null
355
+ return { id: areaGradientId(index), x1: 0, y1: 0, x2: 0, y2: 1, stops }
356
+ }
357
+
358
+ let warnedLogZones = false
359
+
360
+ /** Zone stops are placed linearly in value; a log axis would put them in the wrong place (V2-SPEC
361
+ * § 9.9 precedent): dev throws, production logs once and keeps the series-colour fade. */
362
+ function guardLogZones(onLog: boolean): boolean {
363
+ if (!onLog) return false
364
+ const message = "area: 'zones' cannot use a log axis; use area: true"
365
+ if (process.env.NODE_ENV !== 'production') throw new Error(message)
366
+ // oxlint-disable-next-line no-console -- the production leg of a dev-only throw
367
+ if (!warnedLogZones) console.error(message)
368
+ warnedLogZones = true
369
+ return true
370
+ }
371
+
372
+ /** The gradient resource for a series' `area` fill: zones, a colour ref, or the series colour. */
373
+ function seriesAreaGradient<T, X extends ChartX>(
374
+ s: ChartSeries<T>,
375
+ index: number,
376
+ input: CartesianInput<T, X>,
377
+ axes: Axes<T>,
378
+ ): ChartGradient {
379
+ const plain = areaGradient(
380
+ index,
381
+ typeof s.area === 'string' && s.area !== 'zones' ? s.area : s.color,
382
+ )
383
+ if (s.area !== 'zones') return plain
384
+ const side = axes.onRight(s) ? 'right' : 'left'
385
+ const axisConfig = side === 'right' ? input.y2 : input.y
386
+ if (guardLogZones(axisConfig?.scale === 'log')) return plain
387
+ const zones = (input.zones ?? []).filter((z) => axes.scaleId(z.axisSide) === axes.scaleId(side))
388
+ return (
389
+ zoneAreaGradient(
390
+ index,
391
+ zones,
392
+ input.data.map((d) => s.getValue(d)),
393
+ ) ?? plain
394
+ )
395
+ }
396
+
252
397
  const isBar = <T>(s: ChartSeries<T>): boolean => s.mark === 'bar'
253
398
 
254
399
  /** The two synthetic totals standing in for a stack's bars in its AUTO domain only: the positive
@@ -354,32 +499,93 @@ function resolveAxes<T, X extends ChartX>(
354
499
  }
355
500
  }
356
501
 
357
- /** 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. */
358
530
  function underlayMarks<T, X extends ChartX>(
359
531
  input: CartesianInput<T, X>,
360
532
  model: XScaleModel<X>,
361
533
  axes: Axes<T>,
362
534
  ): ChartMarks {
363
535
  const fractionRows = [{ f0: 0, f1: 1 }]
364
- const zones = (input.zones ?? []).map((z, i) => {
536
+ const zones = (input.zones ?? []).flatMap((z, i) => {
365
537
  const domain = axes.axisOf(z.axisSide).domain
366
- return decorative(
367
- rect(fractionRows, {
368
- id: `zone-${i}`,
369
- x1: 'f0',
370
- x2: 'f1',
371
- y1: () => clampTo(z.from, domain),
372
- y2: () => clampTo(z.to, domain),
373
- xScale: 'zx',
374
- yScale: axes.scaleId(z.axisSide),
375
- fill: z.fill,
376
- inset: 0,
377
- }),
378
- )
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
+ ]
379
567
  })
380
568
  const xZones = (input.xZones ?? []).flatMap((z, i) => {
381
569
  const f = xZoneFractions(z, model)
382
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
+ )
383
589
  return [
384
590
  decorative(
385
591
  rect([{ f0: f[0], f1: f[1] }], {
@@ -393,6 +599,9 @@ function underlayMarks<T, X extends ChartX>(
393
599
  inset: 0,
394
600
  }),
395
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') : []),
396
605
  ]
397
606
  })
398
607
  return [...zones, ...xZones]
@@ -403,15 +612,20 @@ const refLineMarks = <T, X extends ChartX>(
403
612
  input: CartesianInput<T, X>,
404
613
  axes: Axes<T>,
405
614
  ): ChartMarks =>
406
- (input.refLines ?? []).map((r, i) =>
407
- ruleY([r.value], {
408
- id: `ref-${i}`,
409
- stroke: r.color,
410
- strokeOpacity: 1,
411
- strokeWidth: 1,
412
- yScale: axes.scaleId(r.axis),
413
- ...(r.dashed && { strokeDasharray: DASH }),
414
- }),
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
+ ],
415
629
  )
416
630
 
417
631
  function buildScales<T, X extends ChartX>(opts: {
@@ -487,6 +701,25 @@ function rowsOf<T, X extends ChartX>(
487
701
  }
488
702
  }
489
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
+
490
723
  function tooltipSpec<T, X extends ChartX>(opts: {
491
724
  input: CartesianInput<T, X>
492
725
  axes: Axes<T>
@@ -518,25 +751,40 @@ function tooltipSpec<T, X extends ChartX>(opts: {
518
751
  for (const s of rowSeries) {
519
752
  const v = s.getValue(d)
520
753
  if (v === null || !Number.isFinite(v)) continue
521
- rows.push({
522
- label: s.note === undefined ? s.label : `${s.label} · ${s.note}`,
523
- value: s.formatValue
524
- ? s.formatValue(v, d)
525
- : axes.axisOf(axes.onRight(s) ? 'right' : 'left').format(v),
526
- color: s.color,
527
- ...(input.highlighted === s.key && { active: true }),
528
- })
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
+ )
529
763
  }
530
764
  const extra: readonly ChartTooltipRow[] =
531
765
  (input.tooltip || undefined)?.rows?.(d, { visible, hidden }) ?? []
532
- 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
+ }
533
770
  },
534
771
  }
535
772
  }
536
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
+
537
784
  export function compileCartesian<T, X extends ChartX>(
538
785
  input: CartesianInput<T, X>,
539
786
  ): CartesianDefinition {
787
+ guardReservedKeys(input.series)
540
788
  const visible = visibleSeries(input.series, input.hidden ?? new Set<string>())
541
789
  const visibleKeys = new Set(visible.map((s) => s.key))
542
790
  const keys = new Set(input.series.map((s) => s.key))
@@ -544,11 +792,12 @@ export function compileCartesian<T, X extends ChartX>(
544
792
  const axes = resolveAxes(input, visible)
545
793
  const under = underlayMarks(input, model, axes)
546
794
  const over = refLineMarks(input, axes)
795
+ const dots = cursorDotMarks(input, visible, axes)
547
796
  const rows = rowsOf(input, visibleKeys)
548
797
  const formatX = (v: ChartValue): string =>
549
798
  input.formatX ? input.formatX(v as X) : defaultFormatX(v)
550
799
  const gradients = input.series.flatMap((s, i) =>
551
- s.area ? [areaGradient(i, typeof s.area === 'string' ? s.area : s.color)] : [],
800
+ s.area ? [seriesAreaGradient(s, i, input, axes)] : [],
552
801
  )
553
802
 
554
803
  return {
@@ -566,7 +815,7 @@ export function compileCartesian<T, X extends ChartX>(
566
815
  y2Domain: axes.right?.domain,
567
816
  })
568
817
  return {
569
- marks: [...under, ...kindMarks, ...over, crosshair({ x: CROSSHAIR, y: false })],
818
+ marks: [...under, ...kindMarks, ...dots, ...over, crosshair({ x: CROSSHAIR, y: false })],
570
819
  scales: buildScales({ input, model, axes, formatX, cls, zoneScale: under.length > 0 }),
571
820
  theme: CHART_THEME,
572
821
  ...(gradients.length > 0 && { gradients }),
@@ -28,6 +28,7 @@ export {
28
28
  } from './MirroredBars'
29
29
  export { ChartCard, type ChartCardProps, type ChartCardSlot } from './ChartCard'
30
30
  export { chartCursor } from './cursor'
31
+ export { chartCurve, type CurveName } from './curves'
31
32
  export type {
32
33
  AxisConfig,
33
34
  CartesianChartProps,
@@ -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
@@ -21,7 +21,7 @@ import { useCallback } from 'react'
21
21
  import type { ReactElement } from 'react'
22
22
  import { ChartShell } from './CartesianChart'
23
23
  import type { ChartShellProps, ShellContext } from './CartesianChart'
24
- import { defaultFormatX, utcHeaderDate, visibleSeries, xGuide, yGuide } from './compile'
24
+ import { defaultFormatX, engineRow, utcHeaderDate, visibleSeries, xGuide, yGuide } from './compile'
25
25
  import type { CartesianDefinition } from './compile'
26
26
  import { cursorBinding, sourceOnlyFocus } from './cursor'
27
27
  import type { XScaleModel } from './scales'
@@ -205,7 +205,7 @@ function panelDefinition<T, X extends ChartX>(
205
205
  const { x, d } = sourceOf<T>(primary, owned)
206
206
  const extra: readonly ChartTooltipRow[] =
207
207
  tooltip?.rows?.(d, { visible, hidden: hiddenSeries }) ?? []
208
- return { title: header(x, d), rows: [...plan.rows(d), ...extra] }
208
+ return { title: header(x, d), rows: [...plan.rows(d), ...extra.map(engineRow)] }
209
209
  },
210
210
  },
211
211
  }