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
package/dist/telemetry.js CHANGED
@@ -1,14 +1,22 @@
1
1
  import { createContext } from "react";
2
2
  const now = () => performance.timeOrigin + performance.now();
3
3
  const errorName = (error) => error instanceof Error ? error.name : "Error";
4
- function createEmitter(sink) {
5
- if (sink === void 0) return noop;
6
- return (event) => {
4
+ function createLiveEmitter(read) {
5
+ const emit = (event) => {
7
6
  try {
8
- sink(event);
7
+ read()?.(event);
9
8
  } catch {
10
9
  }
11
10
  };
11
+ return Object.assign(emit, {
12
+ startChatTurn(turn) {
13
+ try {
14
+ return read()?.startChatTurn?.(turn);
15
+ } catch {
16
+ return void 0;
17
+ }
18
+ }
19
+ });
12
20
  }
13
21
  function noop() {
14
22
  }
@@ -26,7 +34,7 @@ function routerTelemetry(router) {
26
34
  export {
27
35
  TelemetryContext,
28
36
  bindRouterTelemetry,
29
- createEmitter,
37
+ createLiveEmitter,
30
38
  errorName,
31
39
  now,
32
40
  routerTelemetry
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v2.3.0
1
+ # basalt-ui v2.5.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.5.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.5.0",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, TanStack Charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -49,7 +49,7 @@ import { assertNever } from '../register'
49
49
  import { mintMessageId, mintThreadId } from './id'
50
50
  import { TERMINAL_TOOL_STATES } from './parts'
51
51
  import type { AgentPart, AgentPartDraft } from './parts'
52
- import type { ResumableAgentTransport } from './transport'
52
+ import type { AgentStreamContext, ResumableAgentTransport } from './transport'
53
53
 
54
54
  // ── ai (optional peer) — TYPE-ONLY imports, erased at runtime ────────────────
55
55
  // verbatimModuleSyntax-safe: these never trigger a runtime resolution of 'ai'. The actual runtime
@@ -415,7 +415,7 @@ export function aiSdkTransport<TPart = AgentPart>(
415
415
  async *stream(
416
416
  input: string,
417
417
  signal?: AbortSignal,
418
- ctx?: { readonly messageId: string },
418
+ ctx?: AgentStreamContext,
419
419
  ): AsyncGenerator<TPart> {
420
420
  // Synthesized locally, first — we already know the chat id client-side, so there is no
421
421
  // need to wait on the server to hand us a run id (unlike a river-style protocol).
@@ -439,6 +439,9 @@ export function aiSdkTransport<TPart = AgentPart>(
439
439
  trigger: 'submit-message',
440
440
  messageId: undefined,
441
441
  abortSignal: signal,
442
+ // Per-turn trace context goes to the chat endpoint only (merged over the static headers by
443
+ // AI SDK's HTTP transport); absent, the request is exactly what it was.
444
+ ...(ctx?.headers !== undefined ? { headers: ctx.headers } : {}),
442
445
  })
443
446
  yield* diffChunkStream<TPart>(chatId, chunkStream, readStream, signal)
444
447
  },
@@ -24,7 +24,7 @@ export { toAgentError } from './error'
24
24
  export type { ChatMessage } from './history'
25
25
  export type { AgentThread, ThreadStatus, ThreadsStore } from './thread'
26
26
  export type { AgentOutcome, OutcomeResolver } from './outcome'
27
- export type { AgentTransport, ResumableAgentTransport } from './transport'
27
+ export type { AgentStreamContext, AgentTransport, ResumableAgentTransport } from './transport'
28
28
 
29
29
  // ── Chat UI ───────────────────────────────────────────────────────────────────
30
30
  export { ThreadFeed } from './chat/thread-feed'
@@ -12,10 +12,12 @@
12
12
  * covers: it outlives the run only until a render shows the outcome (`sweepFlushed`).
13
13
  */
14
14
 
15
+ import type { ChatTurnEnd, ChatTurnTrace } from '../telemetry'
16
+
15
17
  /** The part of a rendered `runs` entry this module reads. */
16
18
  type RenderedRun<TPart> = { readonly status: string; readonly parts: TPart[] }
17
19
 
18
- export type RunEnd = 'settled' | 'failed' | 'stopped'
20
+ export type RunEnd = ChatTurnEnd
19
21
 
20
22
  export type RunRecord<TPart> = {
21
23
  readonly controller: AbortController
@@ -28,15 +30,22 @@ export type RunRecord<TPart> = {
28
30
  ended: RunEnd | undefined
29
31
  /** A newer run began on the same thread (set by `beginRun` on the record it replaces). */
30
32
  superseded: boolean
33
+ /** The turn's telemetry `settle`, run once by `settleTrace`. */
34
+ settle: ChatTurnTrace['settle']
31
35
  }
32
36
 
33
37
  export type RunRecords<TPart> = Map<string, RunRecord<TPart>>
34
38
 
35
- /** Registers a live run for `threadId`, replacing whatever record the previous run left. */
39
+ /**
40
+ * Registers a live run for `threadId`, replacing whatever record the previous run left. A previous
41
+ * run still being driven is cut short: its trace settles as `stopped`. `settle` is the new turn's
42
+ * telemetry hook (absent for a resume).
43
+ */
36
44
  export function beginRun<TPart>(
37
45
  records: RunRecords<TPart>,
38
46
  threadId: string,
39
47
  controller: AbortController,
48
+ settle?: ChatTurnTrace['settle'],
40
49
  ): RunRecord<TPart> {
41
50
  const record: RunRecord<TPart> = {
42
51
  controller,
@@ -45,13 +54,28 @@ export function beginRun<TPart>(
45
54
  resolving: false,
46
55
  ended: undefined,
47
56
  superseded: false,
57
+ settle,
48
58
  }
49
59
  const previous = records.get(threadId)
50
- if (previous !== undefined) previous.superseded = true
60
+ if (previous !== undefined) {
61
+ previous.superseded = true
62
+ settleTrace(previous, 'stopped')
63
+ }
51
64
  records.set(threadId, record)
52
65
  return record
53
66
  }
54
67
 
68
+ /** Runs the turn's telemetry `settle` once; a throwing consumer hook never breaks the run. */
69
+ export function settleTrace<TPart>(record: RunRecord<TPart>, end: RunEnd): void {
70
+ const { settle } = record
71
+ record.settle = undefined
72
+ try {
73
+ settle?.(end)
74
+ } catch {
75
+ // a throwing sink must never break the app (§ 7 delivery rules)
76
+ }
77
+ }
78
+
55
79
  /** Marks a run as no longer driven and drops the content it held. */
56
80
  export function endRun<TPart>(record: RunRecord<TPart>, reason: RunEnd): void {
57
81
  record.ended = reason
@@ -16,6 +16,16 @@ import type { AgentPartDraft } from './parts'
16
16
 
17
17
  // ── AgentTransport ────────────────────────────────────────────────────────────
18
18
 
19
+ /**
20
+ * The third `stream` argument. `headers` are extra request headers for THIS turn's request to the
21
+ * transport's own endpoint — today the W3C trace context a telemetry sink's `startChatTurn`
22
+ * returned. Absent unless a sink contributed; a transport that ignores them keeps working.
23
+ */
24
+ export type AgentStreamContext = {
25
+ readonly messageId: string
26
+ readonly headers?: Readonly<Record<string, string>> | undefined
27
+ }
28
+
19
29
  /**
20
30
  * The injected transport seam. `stream(input, signal, ctx)` returns an async generator of parts.
21
31
  * Implement this interface for any backend — fetch-based SSE, AI SDK, or a mock.
@@ -43,11 +53,7 @@ import type { AgentPartDraft } from './parts'
43
53
  * }
44
54
  */
45
55
  export type AgentTransport<TPart = AgentPartDraft, TInput = string> = {
46
- stream: (
47
- input: TInput,
48
- signal?: AbortSignal,
49
- ctx?: { readonly messageId: string },
50
- ) => AsyncGenerator<TPart>
56
+ stream: (input: TInput, signal?: AbortSignal, ctx?: AgentStreamContext) => AsyncGenerator<TPart>
51
57
  /** Optional: resume a previously-started run from its resumeToken (see StartPart). Transports that don't support resumption simply omit this. */
52
58
  resume?: ((resumeToken: string, signal?: AbortSignal) => AsyncGenerator<TPart>) | undefined
53
59
  }
@@ -50,6 +50,7 @@ import { createRunCommitter, isContentPart } from './run-committer'
50
50
  import {
51
51
  beginRun,
52
52
  endRun,
53
+ settleTrace,
53
54
  failedThreadIds,
54
55
  dismissRun,
55
56
  hasLiveRun,
@@ -62,8 +63,8 @@ import type { RunRecord, RunRecords } from './run-records'
62
63
  import type { AgentThread, LiveRunStatus, ThreadStatus, ThreadsStore } from './thread'
63
64
  import { isResumable } from './transport'
64
65
  import type { AgentTransport } from './transport'
65
- import { TelemetryContext, createEmitter, errorName, now } from '../telemetry'
66
- import type { BasaltEvent, Telemetry } from '../telemetry'
66
+ import { TelemetryContext, createLiveEmitter, errorName, now } from '../telemetry'
67
+ import type { BasaltEvent, Telemetry, TraceHeaders } from '../telemetry'
67
68
 
68
69
  // ── ThreadRunState ────────────────────────────────────────────────────────────
69
70
 
@@ -388,6 +389,7 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
388
389
  status: outcome.status,
389
390
  }),
390
391
  )
392
+ settleTrace(record, 'settled')
391
393
  } catch (err) {
392
394
  // Guard: the run ended (stop(), unmount — which abort the controller, so their AbortError lands
393
395
  // here) or a newer stream took the thread over. Only an abort from somewhere else gets past.
@@ -421,6 +423,7 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
421
423
  failure: { error: err, agentError },
422
424
  }),
423
425
  )
426
+ settleTrace(record, 'failed')
424
427
  onError?.({ threadId, error: err })
425
428
  } finally {
426
429
  committer.cancel()
@@ -555,6 +558,24 @@ async function finalizeStop<TPart extends PartLike>(args: {
555
558
  }
556
559
  }
557
560
 
561
+ const TRACEPARENT = /^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$/
562
+ const TRACESTATE_MEMBER = /^[\da-z][\w*/@-]{0,255}=[^\s,=]([^,=]{0,254}[^\s,=])?$/
563
+
564
+ /**
565
+ * The wire form of a turn's trace context: a well-formed `traceparent` (else none — the request
566
+ * goes out untraced, the sink's `settle` still runs) and a `tracestate` only if valid.
567
+ */
568
+ function traceRequestHeaders(headers: TraceHeaders): Record<string, string> | undefined {
569
+ const { traceparent, tracestate } = headers
570
+ if (typeof traceparent !== 'string' || !TRACEPARENT.test(traceparent)) return undefined
571
+ const members = typeof tracestate === 'string' ? tracestate.split(',') : []
572
+ const valid =
573
+ members.length > 0 &&
574
+ members.length <= 32 &&
575
+ members.every((member) => TRACESTATE_MEMBER.test(member.trim()))
576
+ return valid ? { traceparent, tracestate: tracestate as string } : { traceparent }
577
+ }
578
+
558
579
  /** The `chat.turn` event for a turn that completed (`failure` absent) or failed. */
559
580
  function buildTurnEvent(args: {
560
581
  threadId: string
@@ -654,10 +675,7 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
654
675
  toUserPartsRef.current = resolvedToUserParts
655
676
 
656
677
  // Stable over the sink ref, so an inline `telemetry` function never changes what `start` closes over.
657
- const emitTelemetry = useMemo<Telemetry>(
658
- () => createEmitter((event) => telemetryRef.current?.(event)),
659
- [],
660
- )
678
+ const emitTelemetry = useMemo<Telemetry>(() => createLiveEmitter(() => telemetryRef.current), [])
661
679
 
662
680
  // Resolves the transport for `threadId`: the shared object as-is for the plain-object form
663
681
  // (unchanged behavior), or the factory's result — resolved once per thread id and cached in
@@ -707,6 +725,7 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
707
725
  if (record.ended !== undefined) continue
708
726
  abortedThreadIds.push(threadId)
709
727
  record.controller.abort()
728
+ settleTrace(record, 'stopped')
710
729
  }
711
730
  recordsRef.current.clear()
712
731
  if (abortedThreadIds.length === 0) return
@@ -841,8 +860,12 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
841
860
  })
842
861
 
843
862
  const controller = new AbortController()
844
- const record = beginRun(recordsRef.current, threadId, controller)
863
+ // Opened before the request so its context rides on it; closed by `settleTrace` (a resume is
864
+ // the same turn's reconnect, not a new one, and carries none).
865
+ const trace = emitTelemetry.startChatTurn?.({ threadId, messageId: userMessage.id })
866
+ const record = beginRun(recordsRef.current, threadId, controller, trace?.settle)
845
867
  const resolvedTransport = resolveTransport(threadId)
868
+ const headers = trace?.headers === undefined ? undefined : traceRequestHeaders(trace.headers)
846
869
 
847
870
  // Non-awaited: this consumer runs in the background so start() returns immediately and
848
871
  // multiple threads can stream at once.
@@ -855,7 +878,10 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
855
878
  // id instead of a second, unrelated one (R1 — see AgentTransport.stream's doc).
856
879
  generator: withPartIds(
857
880
  threadId,
858
- resolvedTransport.stream(input, controller.signal, { messageId: userMessage.id }),
881
+ resolvedTransport.stream(input, controller.signal, {
882
+ messageId: userMessage.id,
883
+ ...(headers !== undefined ? { headers } : {}),
884
+ }),
859
885
  ),
860
886
  userMessage,
861
887
  recordsRef,
@@ -913,6 +939,7 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
913
939
  const { appended, resolving } = record
914
940
  endRun(record, 'stopped')
915
941
  record.controller.abort()
942
+ settleTrace(record, 'stopped')
916
943
  void finalizeStop({
917
944
  threadId,
918
945
  parts,
@@ -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,9 +14,9 @@ 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
- import { PanelRung, paneX, paneY, rowsOf } from './panel'
19
+ import { PanelRung, chartHeightOf, paneX, paneY, rowsOf } from './panel'
20
20
  import type { PaneBuilder, PanelInput, PanelPlan } from './panel'
21
21
  import { xScaleFor } from './scales'
22
22
  import { rowX } from './types'
@@ -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> => {
@@ -263,13 +269,21 @@ export function DualPanel<T, X extends Date | number = Date | number>(
263
269
  ],
264
270
  scales: {
265
271
  x: { scale: xScale(), axis: false },
266
- y: paneY({ scale: topY, domain: topDomain, format: topFormat, ticks: TOP_TICKS, cls }),
272
+ y: paneY({
273
+ scale: topY,
274
+ domain: topDomain,
275
+ format: topFormat,
276
+ ticks: TOP_TICKS,
277
+ cls,
278
+ height: chartHeightOf(h, topFraction, PANE_GAP),
279
+ }),
267
280
  },
268
281
  }
269
282
  }
270
283
 
271
- const barPane: PaneBuilder = ({ width }) => {
284
+ const barPane: PaneBuilder = ({ width, height: h }) => {
272
285
  const cls = resolveContainerClass(width)
286
+ const whole = chartHeightOf(h, 1 - topFraction, PANE_GAP)
273
287
  const barRows = barOn ? rowsOf(data, x, barSeries) : []
274
288
  const dim = emphasis(highlighted, { key: BAR_KEY })
275
289
  return {
@@ -284,13 +298,14 @@ export function DualPanel<T, X extends Date | number = Date | number>(
284
298
  }),
285
299
  ],
286
300
  scales: {
287
- x: paneX({ scale: xScale(), model, format, cls }),
301
+ x: paneX({ scale: xScale(), model, format, cls, height: whole }),
288
302
  y: paneY({
289
303
  scale: barScale,
290
304
  domain: barDomain,
291
305
  format: bar.format,
292
306
  ticks: BAR_TICKS,
293
307
  cls,
308
+ height: whole,
294
309
  }),
295
310
  },
296
311
  }
@@ -307,26 +322,42 @@ export function DualPanel<T, X extends Date | number = Date | number>(
307
322
  for (const s of visible) {
308
323
  const v = s.getValue(d)
309
324
  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
- })
325
+ out.push(
326
+ seriesRow(
327
+ s,
328
+ s.formatValue ? s.formatValue(v, d) : topTooltipFormat(v),
329
+ highlighted === s.key,
330
+ ),
331
+ )
316
332
  }
317
333
  const v = barOn ? bar.getValue(d) : null
318
334
  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
- })
335
+ out.push(
336
+ engineRow({
337
+ label: bar.label,
338
+ value: bar.formatValue ? bar.formatValue(v, d) : barTooltipFormat(v),
339
+ color: v >= 0 ? bar.positive : bar.negative,
340
+ active: highlighted === BAR_KEY,
341
+ }),
342
+ )
325
343
  return out
326
344
  },
327
345
  }
328
346
  },
329
- [data, x, lines, barSeries, top, fillBetween, bar, topFraction, topFormat, formatX],
347
+ [
348
+ data,
349
+ x,
350
+ lines,
351
+ barSeries,
352
+ top,
353
+ fillBetween,
354
+ bar,
355
+ topFraction,
356
+ topFormat,
357
+ topTooltipFormat,
358
+ barTooltipFormat,
359
+ formatX,
360
+ ],
330
361
  )
331
362
 
332
363
  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,9 +21,9 @@ 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
- import { PanelRung, paneX, paneY, rowsOf } from './panel'
26
+ import { PanelRung, chartHeightOf, paneX, paneY, rowsOf } from './panel'
27
27
  import type { PaneBuilder, PanelInput, PanelPlan } from './panel'
28
28
  import { xScaleFor } from './scales'
29
29
  import { rowX } from './types'
@@ -174,8 +174,9 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
174
174
  on: boolean,
175
175
  max: number,
176
176
  ): PaneBuilder =>
177
- ({ width: w }) => {
177
+ ({ width: w, height: h }) => {
178
178
  const cls = resolveContainerClass(w)
179
+ const whole = chartHeightOf(h, side === 'up' ? upFraction : 1 - upFraction, 0)
179
180
  // The down pane's zero is at its top.
180
181
  const domain: [number, number] = side === 'up' ? [0, max] : [max, 0]
181
182
  const rows: PlotRow<T, X>[] = on ? rowsOf(drawn, x, s) : []
@@ -249,7 +250,7 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
249
250
  scales: {
250
251
  x:
251
252
  side === 'down'
252
- ? paneX({ scale: bandScale(), model, format, cls })
253
+ ? paneX({ scale: bandScale(), model, format, cls, height: whole })
253
254
  : { scale: bandScale(), axis: false },
254
255
  y: paneY({
255
256
  scale: scaleLinear().domain(domain),
@@ -257,6 +258,7 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
257
258
  format: cfg.format,
258
259
  ticks: cfg.ticks ?? DEFAULT_PANE_TICKS,
259
260
  cls,
261
+ height: whole,
260
262
  }),
261
263
  zx: { channel: 'x', scale: scaleLinear().domain([0, 1]), axis: false },
262
264
  },
@@ -277,12 +279,13 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
277
279
  // Each pane's row falls back to ITS OWN formatter: the panes are in different units.
278
280
  const fallback =
279
281
  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
- })
282
+ out.push(
283
+ seriesRow(
284
+ s,
285
+ s.formatValue ? s.formatValue(v, d) : fallback(v),
286
+ highlighted === s.key,
287
+ ),
288
+ )
286
289
  }
287
290
  return out
288
291
  },