basalt-ui 2.3.0 → 2.5.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 (65) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/MIGRATING.md +79 -12
  3. package/agent/rules/basalt-state.md +1 -1
  4. package/configs/lefthook.yml +4 -1
  5. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  6. package/dist/agent/ai-sdk-transport.js +4 -1
  7. package/dist/agent/index.d.ts +1 -1
  8. package/dist/agent/index.d.ts.map +1 -1
  9. package/dist/agent/run-records.d.ts +12 -3
  10. package/dist/agent/run-records.d.ts.map +1 -1
  11. package/dist/agent/run-records.js +16 -3
  12. package/dist/agent/transport.d.ts +10 -3
  13. package/dist/agent/transport.d.ts.map +1 -1
  14. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  15. package/dist/agent/use-agent-thread-runs.js +23 -7
  16. package/dist/charts/CartesianChart.d.ts.map +1 -1
  17. package/dist/charts/CartesianChart.js +6 -2
  18. package/dist/charts/DualPanel.d.ts +5 -1
  19. package/dist/charts/DualPanel.d.ts.map +1 -1
  20. package/dist/charts/DualPanel.js +46 -19
  21. package/dist/charts/Heatmap.js +2 -2
  22. package/dist/charts/Lines.js +2 -2
  23. package/dist/charts/MirroredBars.d.ts.map +1 -1
  24. package/dist/charts/MirroredBars.js +14 -11
  25. package/dist/charts/compile.d.ts +19 -2
  26. package/dist/charts/compile.d.ts.map +1 -1
  27. package/dist/charts/compile.js +192 -42
  28. package/dist/charts/marks.d.ts +8 -0
  29. package/dist/charts/marks.d.ts.map +1 -1
  30. package/dist/charts/marks.js +22 -1
  31. package/dist/charts/panel.d.ts +6 -0
  32. package/dist/charts/panel.d.ts.map +1 -1
  33. package/dist/charts/panel.js +12 -3
  34. package/dist/charts/types.d.ts +39 -1
  35. package/dist/charts/types.d.ts.map +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/provider/index.d.ts.map +1 -1
  39. package/dist/provider/index.js +8 -2
  40. package/dist/state/fields.d.ts +14 -3
  41. package/dist/state/fields.d.ts.map +1 -1
  42. package/dist/state/fields.js +41 -9
  43. package/dist/telemetry.d.ts +58 -4
  44. package/dist/telemetry.d.ts.map +1 -1
  45. package/dist/telemetry.js +13 -5
  46. package/llms.txt +2 -2
  47. package/package.json +1 -1
  48. package/src/agent/ai-sdk-transport.ts +5 -2
  49. package/src/agent/index.ts +1 -1
  50. package/src/agent/run-records.ts +27 -3
  51. package/src/agent/transport.ts +11 -5
  52. package/src/agent/use-agent-thread-runs.ts +35 -8
  53. package/src/charts/CartesianChart.tsx +7 -2
  54. package/src/charts/DualPanel.tsx +50 -19
  55. package/src/charts/Heatmap.tsx +2 -2
  56. package/src/charts/Lines.tsx +2 -2
  57. package/src/charts/MirroredBars.tsx +13 -10
  58. package/src/charts/compile.ts +261 -46
  59. package/src/charts/marks.ts +35 -1
  60. package/src/charts/panel.tsx +18 -3
  61. package/src/charts/types.ts +37 -1
  62. package/src/index.ts +1 -1
  63. package/src/provider/index.tsx +8 -2
  64. package/src/state/fields.ts +79 -21
  65. package/src/telemetry.ts +70 -7
@@ -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'
@@ -23,10 +23,12 @@ import type {
23
23
  ResponsiveChartDefinition,
24
24
  StoredChartSpec,
25
25
  } from '@tanstack/charts/types/core'
26
+ import { SHORT_CHART_HEIGHT } from './box'
26
27
  import { fmtCompact } from '../format/number'
27
28
  import { VX } from '../tokens'
28
29
  import type { ContainerClass } from '../tokens/size-classes'
29
30
  import { cursorBinding, sourceOnlyFocus } from './cursor'
31
+ import { cursorDots } from './marks'
30
32
  import { resolveContainerClass } from '../tokens/size-classes'
31
33
  import { resolveYDomain, xScaleFor, xZoneFractions, yScaleFor } from './scales'
32
34
  import type { XScaleModel } from './scales'
@@ -39,6 +41,8 @@ import type {
39
41
  ChartTooltipRow,
40
42
  ChartX,
41
43
  PlotRow,
44
+ RefLine,
45
+ ZoneEdge,
42
46
  ZoneSpec,
43
47
  } from './types'
44
48
 
@@ -94,6 +98,14 @@ const LADDER = {
94
98
  const INSIDE_LABEL_DX = 4
95
99
  const INSIDE_LABEL_DY = -6
96
100
 
101
+ /**
102
+ * Y labels go inside the plot only for the compact class under `SHORT_CHART_HEIGHT` (the box's own
103
+ * short signal); otherwise they sit in a left gutter. `height` is the whole chart's, `0` before it
104
+ * is measured, which keeps the gutter rather than flipping a first paint inside.
105
+ */
106
+ const labelsInside = (cls: ContainerClass, height: number): boolean =>
107
+ cls === 'compact' && height > 0 && height < SHORT_CHART_HEIGHT
108
+
97
109
  export function defaultNumberFormat(v: number): string {
98
110
  return fmtCompact(v, { digits: Math.abs(v) >= 1000 ? 1 : 2 })
99
111
  }
@@ -136,6 +148,83 @@ export function defaultFormatX(v: ChartValue): string {
136
148
  const clampTo = (v: number, [lo, hi]: readonly [number, number]): number =>
137
149
  Math.min(Math.max(v, Math.min(lo, hi)), Math.max(lo, hi))
138
150
 
151
+ /**
152
+ * A tooltip swatch is the engine's `background` shorthand, so a dashed one is a gradient: a 2px
153
+ * dashed line across the swatch's middle, in the row color.
154
+ */
155
+ export const dashedSwatch = (color: string): string =>
156
+ `repeating-linear-gradient(90deg, ${color} 0 2px, transparent 2px 4px) center / 100% 2px no-repeat`
157
+
158
+ /** A basalt tooltip row as the engine's: the swatch shape folded into the row color. */
159
+ export function engineRow(
160
+ row: ChartTooltipRow & { active?: boolean | undefined },
161
+ ): EngineTooltipRow {
162
+ const { label, value, color, swatch, active } = row
163
+ return {
164
+ label,
165
+ value,
166
+ ...(color !== undefined && { color: swatch === 'dashed' ? dashedSwatch(color) : color }),
167
+ ...(active && { active }),
168
+ }
169
+ }
170
+
171
+ /** A series row of a tooltip: the label with its note, the swatch following the series' dash. */
172
+ export function seriesRow(
173
+ s: Pick<ChartSeries<never>, 'label' | 'note' | 'color' | 'dash'>,
174
+ value: string,
175
+ active: boolean,
176
+ ): EngineTooltipRow {
177
+ return engineRow({
178
+ label: s.note === undefined ? s.label : `${s.label} · ${s.note}`,
179
+ value,
180
+ color: s.color,
181
+ swatch: s.dash === 'dashed' ? 'dashed' : 'solid',
182
+ active,
183
+ })
184
+ }
185
+
186
+ /** Keys the compiler reserves for the legend entries it derives from `refLines`. */
187
+ const REF_KEY_PREFIX = '__ref-'
188
+
189
+ /** The legend key of a reference line that names itself (`RefLine.legend`). */
190
+ const refLegendKey = (index: number): string => `${REF_KEY_PREFIX}${index}`
191
+
192
+ /**
193
+ * A reference line is drawn translucent (`*-ref`, 20-30% alpha) so it recedes behind the data, but
194
+ * a 16x8 swatch at that alpha vanishes on the dark surface. The legend takes the status solid,
195
+ * which the color gate holds to 3:1 on the panel in both schemes, like a series swatch.
196
+ */
197
+ const SWATCH_SOLID: ReadonlyMap<string, string> = new Map([
198
+ [VX.goodRef, VX.goodSolid],
199
+ [VX.badRef, VX.badSolid],
200
+ [VX.warnRef, VX.warnSolid],
201
+ // The tinted status fills are as translucent, and a consumer may pass either as a line color.
202
+ [VX.good, VX.goodSolid],
203
+ [VX.goodSoft, VX.goodSolid],
204
+ [VX.bad, VX.badSolid],
205
+ [VX.warn, VX.warnSolid],
206
+ ])
207
+
208
+ /** Legend-only entries for the reference lines that name themselves, in line order. */
209
+ export function refLegendSeries<T>(refLines: readonly RefLine[] | undefined): ChartSeries<T>[] {
210
+ return (refLines ?? []).flatMap((r, i) =>
211
+ r.legend === undefined
212
+ ? []
213
+ : [
214
+ {
215
+ key: refLegendKey(i),
216
+ label: r.legend.label,
217
+ color: SWATCH_SOLID.get(r.color) ?? r.color,
218
+ role: 'reference' as const,
219
+ tooltip: false,
220
+ getValue: () => null,
221
+ ...(r.legend.mark !== undefined && { mark: r.legend.mark }),
222
+ ...(r.dashed && { dash: 'dashed' as const }),
223
+ },
224
+ ],
225
+ )
226
+ }
227
+
139
228
  /** The visible series: not toggled off, and not a companion whose parent is. */
140
229
  export function visibleSeries<T>(
141
230
  series: readonly ChartSeries<T>[],
@@ -154,8 +243,10 @@ export function yGuide<T>(opts: {
154
243
  cls: ContainerClass
155
244
  side: 'left' | 'right'
156
245
  grid: boolean
246
+ /** The whole chart's height: decides inside or gutter labels. */
247
+ height: number
157
248
  }): Omit<ChartPositionScaleOptions<number>, 'scale'> {
158
- const { cfg, axis, cls, side, grid } = opts
249
+ const { cfg, axis, cls, side, grid, height } = opts
159
250
  const ladder = LADDER[cls]
160
251
  const ticks =
161
252
  typeof cfg?.ticks === 'object'
@@ -163,8 +254,8 @@ export function yGuide<T>(opts: {
163
254
  : { count: typeof cfg?.ticks === 'number' ? cfg.ticks : ladder.yTicks }
164
255
  const gridLine = grid ? { grid: GRID_LINE } : {}
165
256
  if (cls === 'micro') return { ...gridLine, axis: false }
166
- // Compact: labels sit INSIDE the plot, above their grid line, so the plot keeps the full width.
167
- const inside = cls === 'compact'
257
+ // Labels sit INSIDE the plot, above their grid line, so the plot keeps the full width.
258
+ const inside = labelsInside(cls, height)
168
259
  return {
169
260
  ...gridLine,
170
261
  ...(side === 'right' && { side: 'right' as const }),
@@ -212,9 +303,12 @@ export function xGuide<X extends ChartX>(opts: {
212
303
  xTicks: number | readonly X[] | undefined
213
304
  format: (v: ChartValue) => string
214
305
  cls: ContainerClass
306
+ /** The whole chart's height: decides whether the left margin is locked to zero. */
307
+ height: number
215
308
  }): Omit<ChartPositionScaleOptions<ChartValue>, 'scale'> {
216
- const { model, xTicks, format, cls } = opts
309
+ const { model, xTicks, format, cls, height } = opts
217
310
  const ladder = LADDER[cls]
311
+ const inside = labelsInside(cls, height)
218
312
  const ticks = xTickPolicy(model, xTicks ?? ladder.xTicks)
219
313
  return {
220
314
  axis: {
@@ -224,8 +318,8 @@ export function xGuide<X extends ChartX>(opts: {
224
318
  fontSize: ladder.font,
225
319
  opacity: 1,
226
320
  thin: true,
227
- // A locked-zero left margin (compact) must not clip the first label.
228
- ...(cls === 'compact' && {
321
+ // A locked-zero left margin (inside labels) must not clip the first label.
322
+ ...(inside && {
229
323
  anchor: ({ index }: { index: number }) => (index === 0 ? ('start' as const) : undefined),
230
324
  }),
231
325
  },
@@ -435,32 +529,93 @@ function resolveAxes<T, X extends ChartX>(
435
529
  }
436
530
  }
437
531
 
438
- /** Fixed paint under the kind's marks: zones, then x-zones. */
532
+ /** The sides a `ZoneEdge` names, in a fixed order: the lower bound first. */
533
+ const edgeSides = <A extends string, B extends string>(
534
+ edge: ZoneEdge<A | B> | undefined,
535
+ lower: A,
536
+ upper: B,
537
+ ): { lower: boolean; upper: boolean } => ({
538
+ lower: edge !== undefined && (edge.side === lower || edge.side === 'both'),
539
+ upper: edge !== undefined && (edge.side === upper || edge.side === 'both'),
540
+ })
541
+
542
+ const edgePaint = (edge: ZoneEdge<string>) => ({
543
+ stroke: edge.color,
544
+ strokeOpacity: 1,
545
+ strokeWidth: edge.width ?? 1,
546
+ ...(edge.dashed && { strokeDasharray: DASH }),
547
+ })
548
+
549
+ /**
550
+ * An edge rule at `position` when it lies strictly inside `(lo, hi)`: a bound at or past the plot
551
+ * edge (infinite, outside the domain, exactly on it) has no rule, for y- and x-zones alike.
552
+ */
553
+ const edgeRule = (
554
+ position: number | null,
555
+ [lo, hi]: readonly [number, number],
556
+ make: (at: number) => ChartMarks[number],
557
+ ): ChartMarks => (position !== null && position > lo && position < hi ? [make(position)] : [])
558
+
559
+ /** Fixed paint under the kind's marks: zones, then x-zones, each with its edge rules. */
439
560
  function underlayMarks<T, X extends ChartX>(
440
561
  input: CartesianInput<T, X>,
441
562
  model: XScaleModel<X>,
442
563
  axes: Axes<T>,
443
564
  ): ChartMarks {
444
565
  const fractionRows = [{ f0: 0, f1: 1 }]
445
- const zones = (input.zones ?? []).map((z, i) => {
566
+ const zones = (input.zones ?? []).flatMap((z, i) => {
446
567
  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
- )
568
+ const [lo, hi] = [Math.min(...domain), Math.max(...domain)]
569
+ const rule = (value: number, name: string) =>
570
+ edgeRule(value, [lo, hi], (at) =>
571
+ decorative(
572
+ ruleY([at], {
573
+ id: `zone-${i}-${name}`,
574
+ yScale: axes.scaleId(z.axisSide),
575
+ ...(z.edge && edgePaint(z.edge)),
576
+ }),
577
+ ),
578
+ )
579
+ const sides = edgeSides(z.edge, 'bottom', 'top')
580
+ return [
581
+ decorative(
582
+ rect(fractionRows, {
583
+ id: `zone-${i}`,
584
+ x1: 'f0',
585
+ x2: 'f1',
586
+ y1: () => clampTo(z.from, domain),
587
+ y2: () => clampTo(z.to, domain),
588
+ xScale: 'zx',
589
+ yScale: axes.scaleId(z.axisSide),
590
+ fill: z.fill,
591
+ inset: 0,
592
+ }),
593
+ ),
594
+ ...(sides.lower ? rule(Math.min(z.from, z.to), 'bottom') : []),
595
+ ...(sides.upper ? rule(Math.max(z.from, z.to), 'top') : []),
596
+ ]
460
597
  })
461
598
  const xZones = (input.xZones ?? []).flatMap((z, i) => {
462
599
  const f = xZoneFractions(z, model)
463
600
  if (!f) return []
601
+ const sides = edgeSides(z.edge, 'start', 'end')
602
+ // The bound's own plot fraction (widened like the band), unclamped: `f` is clamped to the plot.
603
+ const widen = z.align === 'edge' ? model.halfStep : 0
604
+ const bound = (value: X | undefined, sign: 1 | -1): number | null => {
605
+ const fraction = value === undefined ? null : model.fraction(value)
606
+ return fraction === null ? null : fraction + sign * widen
607
+ }
608
+ const rule = (value: X | undefined, sign: 1 | -1, name: string) =>
609
+ edgeRule(bound(value, sign), [0, 1], (at) =>
610
+ decorative(
611
+ ruleX([at], {
612
+ id: `xzone-${i}-${name}`,
613
+ x: (v: number) => v,
614
+ xScale: 'zx',
615
+ ...(z.edge && edgePaint(z.edge)),
616
+ }),
617
+ ),
618
+ )
464
619
  return [
465
620
  decorative(
466
621
  rect([{ f0: f[0], f1: f[1] }], {
@@ -474,6 +629,9 @@ function underlayMarks<T, X extends ChartX>(
474
629
  inset: 0,
475
630
  }),
476
631
  ),
632
+ // An omitted bound is the plot edge: nothing to draw a rule on.
633
+ ...(sides.lower ? rule(z.from, -1, 'start') : []),
634
+ ...(sides.upper ? rule(z.to, 1, 'end') : []),
477
635
  ]
478
636
  })
479
637
  return [...zones, ...xZones]
@@ -484,15 +642,20 @@ const refLineMarks = <T, X extends ChartX>(
484
642
  input: CartesianInput<T, X>,
485
643
  axes: Axes<T>,
486
644
  ): 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
- }),
645
+ (input.refLines ?? []).flatMap((r, i) =>
646
+ // A line that names itself in the legend can be toggled off there.
647
+ input.hidden?.has(refLegendKey(i))
648
+ ? []
649
+ : [
650
+ ruleY([r.value], {
651
+ id: `ref-${i}`,
652
+ stroke: r.color,
653
+ strokeOpacity: 1,
654
+ strokeWidth: 1,
655
+ yScale: axes.scaleId(r.axis),
656
+ ...(r.dashed && { strokeDasharray: DASH }),
657
+ }),
658
+ ],
496
659
  )
497
660
 
498
661
  function buildScales<T, X extends ChartX>(opts: {
@@ -501,18 +664,26 @@ function buildScales<T, X extends ChartX>(opts: {
501
664
  axes: Axes<T>
502
665
  formatX: (v: ChartValue) => string
503
666
  cls: ContainerClass
667
+ height: number
504
668
  zoneScale: boolean
505
669
  }): StoredChartSpec['scales'] {
506
- const { input, model, axes, formatX, cls, zoneScale } = opts
670
+ const { input, model, axes, formatX, cls, height, zoneScale } = opts
507
671
  const { left, right } = axes
508
672
  return {
509
673
  x: {
510
674
  scale: model.scale,
511
- ...xGuide({ model, xTicks: input.xTicks, format: formatX, cls }),
675
+ ...xGuide({ model, xTicks: input.xTicks, format: formatX, cls, height }),
512
676
  },
513
677
  y: {
514
678
  scale: yScaleFor(input.y, left.domain),
515
- ...yGuide({ cfg: input.y, axis: left, cls, side: 'left', grid: input.y?.grid ?? true }),
679
+ ...yGuide({
680
+ cfg: input.y,
681
+ axis: left,
682
+ cls,
683
+ height,
684
+ side: 'left',
685
+ grid: input.y?.grid ?? true,
686
+ }),
516
687
  },
517
688
  ...(right && {
518
689
  y2: {
@@ -522,6 +693,7 @@ function buildScales<T, X extends ChartX>(opts: {
522
693
  cfg: input.y2,
523
694
  axis: right,
524
695
  cls,
696
+ height,
525
697
  side: 'right',
526
698
  grid: input.y2?.grid ?? false,
527
699
  }),
@@ -568,6 +740,25 @@ function rowsOf<T, X extends ChartX>(
568
740
  }
569
741
  }
570
742
 
743
+ /** The cursor dots of the visible series that opt in with `cursorValue`. */
744
+ function cursorDotMarks<T, X extends ChartX>(
745
+ input: CartesianInput<T, X>,
746
+ visible: readonly ChartSeries<T>[],
747
+ axes: Axes<T>,
748
+ ): ChartMarks {
749
+ return visible.flatMap((s) => {
750
+ const { cursorValue } = s
751
+ if (!cursorValue) return []
752
+ const rows = input.data.flatMap((d): PlotRow<T, X>[] => {
753
+ const y = cursorValue(d)
754
+ return y === null || !Number.isFinite(y)
755
+ ? []
756
+ : [{ x: input.x(d), y, series: s.key, datum: d }]
757
+ })
758
+ return [cursorDots(s, rows, axes.onRight(s) ? 'y2' : undefined)]
759
+ })
760
+ }
761
+
571
762
  function tooltipSpec<T, X extends ChartX>(opts: {
572
763
  input: CartesianInput<T, X>
573
764
  axes: Axes<T>
@@ -599,25 +790,40 @@ function tooltipSpec<T, X extends ChartX>(opts: {
599
790
  for (const s of rowSeries) {
600
791
  const v = s.getValue(d)
601
792
  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
- })
793
+ rows.push(
794
+ seriesRow(
795
+ s,
796
+ s.formatValue
797
+ ? s.formatValue(v, d)
798
+ : axes.axisOf(axes.onRight(s) ? 'right' : 'left').format(v),
799
+ input.highlighted === s.key,
800
+ ),
801
+ )
610
802
  }
611
803
  const extra: readonly ChartTooltipRow[] =
612
804
  (input.tooltip || undefined)?.rows?.(d, { visible, hidden }) ?? []
613
- return { title: tooltipHeader(input, formatX, x, d), rows: [...rows, ...extra] }
805
+ return {
806
+ title: tooltipHeader(input, formatX, x, d),
807
+ rows: [...rows, ...extra.map(engineRow)],
808
+ }
614
809
  },
615
810
  }
616
811
  }
617
812
 
813
+ /** A consumer key in the legend's reserved `__ref-` namespace would collide with a reference line. */
814
+ function guardReservedKeys<T>(series: readonly ChartSeries<T>[]): void {
815
+ if (process.env.NODE_ENV === 'production') return
816
+ const clash = series.find((s) => s.key.startsWith(REF_KEY_PREFIX))
817
+ if (clash)
818
+ throw new Error(
819
+ `[basalt] CartesianChart: the series key "${clash.key}" is reserved (\`${REF_KEY_PREFIX}\` prefix names reference-line legend entries)`,
820
+ )
821
+ }
822
+
618
823
  export function compileCartesian<T, X extends ChartX>(
619
824
  input: CartesianInput<T, X>,
620
825
  ): CartesianDefinition {
826
+ guardReservedKeys(input.series)
621
827
  const visible = visibleSeries(input.series, input.hidden ?? new Set<string>())
622
828
  const visibleKeys = new Set(visible.map((s) => s.key))
623
829
  const keys = new Set(input.series.map((s) => s.key))
@@ -625,6 +831,7 @@ export function compileCartesian<T, X extends ChartX>(
625
831
  const axes = resolveAxes(input, visible)
626
832
  const under = underlayMarks(input, model, axes)
627
833
  const over = refLineMarks(input, axes)
834
+ const dots = cursorDotMarks(input, visible, axes)
628
835
  const rows = rowsOf(input, visibleKeys)
629
836
  const formatX = (v: ChartValue): string =>
630
837
  input.formatX ? input.formatX(v as X) : defaultFormatX(v)
@@ -647,12 +854,20 @@ export function compileCartesian<T, X extends ChartX>(
647
854
  y2Domain: axes.right?.domain,
648
855
  })
649
856
  return {
650
- marks: [...under, ...kindMarks, ...over, crosshair({ x: CROSSHAIR, y: false })],
651
- scales: buildScales({ input, model, axes, formatX, cls, zoneScale: under.length > 0 }),
857
+ marks: [...under, ...kindMarks, ...dots, ...over, crosshair({ x: CROSSHAIR, y: false })],
858
+ scales: buildScales({
859
+ input,
860
+ model,
861
+ axes,
862
+ formatX,
863
+ cls,
864
+ height,
865
+ zoneScale: under.length > 0,
866
+ }),
652
867
  theme: CHART_THEME,
653
868
  ...(gradients.length > 0 && { gradients }),
654
869
  clip: true,
655
- ...(cls === 'compact' && { margin: { left: 0, ...(axes.right && { right: 0 }) } }),
870
+ ...(labelsInside(cls, height) && { margin: { left: 0, ...(axes.right && { right: 0 }) } }),
656
871
  }
657
872
  },
658
873
  // Followers paint the crosshair only, unless the chart opts into following tooltips.
@@ -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'
@@ -83,12 +83,15 @@ export const paneY = (opts: {
83
83
  format: (v: number) => string
84
84
  ticks: number
85
85
  cls: ContainerClass
86
+ /** The whole chart's height ({@link chartHeightOf}). */
87
+ height: number
86
88
  }): StoredChartSpec['scales'][string] => ({
87
89
  scale: opts.scale,
88
90
  ...yGuide({
89
91
  cfg: { ticks: opts.ticks },
90
92
  axis: { domain: opts.domain, format: opts.format },
91
93
  cls: opts.cls,
94
+ height: opts.height,
92
95
  side: 'left',
93
96
  grid: true,
94
97
  }),
@@ -102,11 +105,23 @@ export const paneX = <X extends ChartX>(opts: {
102
105
  model: XScaleModel<X>
103
106
  format: (v: ChartValue) => string
104
107
  cls: ContainerClass
108
+ /** The whole chart's height ({@link chartHeightOf}). */
109
+ height: number
105
110
  }): StoredChartSpec['scales'][string] => ({
106
111
  scale: opts.scale,
107
- ...xGuide({ model: opts.model, xTicks: undefined, format: opts.format, cls: opts.cls }),
112
+ ...xGuide({
113
+ model: opts.model,
114
+ xTicks: undefined,
115
+ format: opts.format,
116
+ cls: opts.cls,
117
+ height: opts.height,
118
+ }),
108
119
  })
109
120
 
121
+ /** The whole chart's height from one pane's: a pane gets `share` of the height less the gap. */
122
+ export const chartHeightOf = (paneHeight: number, share: number, gap: number): number =>
123
+ paneHeight > 0 && share > 0 ? paneHeight / share + gap : 0
124
+
110
125
  /** One row per datum for a series: a `null` or non-finite value is kept as a gap. */
111
126
  export function rowsOf<T, X extends ChartX>(
112
127
  data: readonly T[],
@@ -205,7 +220,7 @@ function panelDefinition<T, X extends ChartX>(
205
220
  const { x, d } = sourceOf<T>(primary, owned)
206
221
  const extra: readonly ChartTooltipRow[] =
207
222
  tooltip?.rows?.(d, { visible, hidden: hiddenSeries }) ?? []
208
- return { title: header(x, d), rows: [...plan.rows(d), ...extra] }
223
+ return { title: header(x, d), rows: [...plan.rows(d), ...extra.map(engineRow)] }
209
224
  },
210
225
  },
211
226
  }
@@ -31,6 +31,13 @@ export type ChartSeries<T> = {
31
31
  * tier, a config value) is a step: a smoothed curve draws values that never existed. */
32
32
  curve?: CurveName | undefined
33
33
  strokeWidth?: number | undefined
34
+ /**
35
+ * What a `null` value does to the line (and its `area`). `'break'` (default) leaves a gap;
36
+ * `'connect'` draws through the series' non-null points only, skipping the null rows, so a series
37
+ * that is null on most shared x values (one lift in a wide table) is one continuous line. Points,
38
+ * markers, the cursor and the tooltip still read real values: a null row has no tooltip row.
39
+ */
40
+ gaps?: 'break' | 'connect' | undefined
34
41
  /** Dims the plotted stroke (a faint companion line). Strokes only. */
35
42
  strokeOpacity?: number | undefined
36
43
  /** Bars and areas. */
@@ -53,6 +60,12 @@ export type ChartSeries<T> = {
53
60
  note?: string | undefined
54
61
  /** Per-series tooltip value format; receives the datum so a row can read more than the value. */
55
62
  formatValue?: ((v: number, d: T) => string) | undefined
63
+ /**
64
+ * A cursor dot for a series whose mark the consumer draws itself (`marks`, so `getValue` stays
65
+ * `null`): the y of the dot at each datum, `null` for none. The dot shows while the cursor is on
66
+ * that datum's x and takes the series color. A series drawn from `plot.rows` already has one.
67
+ */
68
+ cursorValue?: ((d: T) => number | null) | undefined
56
69
  /** A marker at a point (a PR star, a status dot); `null` for none. */
57
70
  getMarker?:
58
71
  | ((d: T) => {
@@ -95,12 +108,24 @@ export type AxisConfig<T> = {
95
108
  scale?: 'linear' | 'log' | undefined
96
109
  }
97
110
 
111
+ /** A rule along a band's boundary, painted with the band. A bound at the plot edge draws none. */
112
+ export type ZoneEdge<S extends string> = {
113
+ side: S | 'both'
114
+ color: string
115
+ dashed?: boolean | undefined
116
+ /** Default `1`. */
117
+ width?: number | undefined
118
+ }
119
+
98
120
  /** A horizontal band on one y axis. `±Infinity` reaches the plot edge. */
99
121
  export type ZoneSpec = {
100
122
  from: number
101
123
  to: number
102
124
  fill: string
103
125
  axisSide?: 'left' | 'right' | undefined
126
+ /** A rule on the band's visual `top` (the larger bound) and/or `bottom` (the smaller), whichever
127
+ * of `from`/`to` that is. */
128
+ edge?: ZoneEdge<'top' | 'bottom'> | undefined
104
129
  }
105
130
 
106
131
  /**
@@ -113,6 +138,8 @@ export type XZoneSpec<X extends ChartX = ChartX> = {
113
138
  to?: X | undefined
114
139
  fill: string
115
140
  align?: 'center' | 'edge' | undefined
141
+ /** A rule on the band's `start` (`from`) and/or `end` (`to`) boundary; an omitted bound has none. */
142
+ edge?: ZoneEdge<'start' | 'end'> | undefined
116
143
  }
117
144
 
118
145
  /** A horizontal reference line — annotation only, no domain role beyond staying inside the plot. */
@@ -121,6 +148,13 @@ export type RefLine = {
121
148
  color: string
122
149
  dashed?: boolean | undefined
123
150
  axis?: 'left' | 'right' | undefined
151
+ /**
152
+ * Names the line in the legend (its color, dashed as `dashed`; a toggle hides the line), so a
153
+ * threshold needs no always-`null` series. `mark` is the swatch shape. Default `'line'`. The
154
+ * toggle state is keyed by the line's index in `refLines`: reordering the array while a line is
155
+ * toggled off moves the "off" to whichever line now sits at that index.
156
+ */
157
+ legend?: { label: string; mark?: 'line' | 'bar' | undefined } | undefined
124
158
  }
125
159
 
126
160
  /** A fill between the line and a value, on one side of it, tracking the line. */
@@ -138,7 +172,9 @@ export type ChartTooltipRow = {
138
172
  label: string
139
173
  value: string
140
174
  /** The row swatch; omitted, the row has none. */
141
- color?: string
175
+ color?: string | undefined
176
+ /** The swatch shape; `'dashed'` for a reference or dashed series. Default `'solid'`. */
177
+ swatch?: 'solid' | 'dashed' | undefined
142
178
  }
143
179
 
144
180
  export type ChartTooltipConfig<T, X extends ChartX = ChartX> = {
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ export {
12
12
  type BasaltRouter,
13
13
  type BasaltRouterContext,
14
14
  } from './provider/create-router'
15
- export type { BasaltEvent } from './telemetry'
15
+ export type { BasaltEvent, ChatTurnEnd, ChatTurnTrace, Telemetry, TraceHeaders } from './telemetry'
16
16
  export {
17
17
  defineNav,
18
18
  navGroup,