@dbx-tools/appkit-mastra 0.1.13 → 0.1.19

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.
package/src/chart.ts CHANGED
@@ -1,42 +1,34 @@
1
1
  /**
2
2
  * Chart-rendering primitives.
3
3
  *
4
- * Three surfaces, one shared brain:
5
- *
6
- * - {@link buildRenderDataTool}: a Mastra tool the model calls
7
- * ("here is a dataset, render it as a chart"). The tool's
8
- * `execute` emits a `kind: "chart"` event with the raw rows to
9
- * `ctx.writer` synchronously, kicks off the chart-planner agent,
10
- * and `await`s the planner promise before returning so the
11
- * planner's latency is attributed to this tool's trace span.
12
- * The LLM-bound output is just `{ chartId }`, so its context
13
- * stays flat regardless of dataset size.
14
- *
15
- * - {@link emitChartWithPlanning}: the underlying helper that both
16
- * `render_data` and Genie's `drainGenieStream` call. Mints the
17
- * `chartId`, fires the dataset event immediately, runs the
18
- * planner in the background, and returns `{ chartId,
19
- * plannerPromise }` so callers can choose to await for trace
20
- * shape or fire-and-forget.
4
+ * Two surfaces, one shared brain:
21
5
  *
22
6
  * - {@link runChartPlanner}: the chart-planner Agent + ECOption
23
- * expansion as a plain async function. Used internally by
24
- * {@link emitChartWithPlanning}; producers shouldn't reach for
25
- * it directly so chart events keep a single wire-format
26
- * contract.
7
+ * expansion as a plain async function. Takes a dataset and
8
+ * returns a promise that resolves to a full `EChartsOption`
9
+ * JSON plus the chosen `chartType`. No background work, no
10
+ * writer side-effects, no id allocation - callers stitch the
11
+ * result into whatever shape their producer needs.
12
+ *
13
+ * - {@link buildRenderDataTool}: a Mastra tool the model calls
14
+ * ("here is a dataset, render it as a chart"). Mints a short
15
+ * `chartId`, `await`s {@link runChartPlanner} so the planner
16
+ * latency is attributed to this tool's trace span, emits one
17
+ * `type: "chart"` writer event carrying the dataset + resolved
18
+ * `option`, and returns `{ chartId }` to the model. The
19
+ * LLM-bound output stays flat regardless of dataset size.
27
20
  *
28
21
  * The model wires the chart into its reply by emitting the marker
29
22
  * `[[chart:<chartId>]]` on its own line in markdown. The chat
30
23
  * client splits the assistant text on these markers and drops a
31
- * `<ChartSlot>` in at the position the model placed it; the slot
32
- * shows a skeleton until the second `kind: "chart"` event (with
33
- * the resolved `EChartsOption`) arrives, then swaps in the
34
- * rendered Echarts visualisation.
24
+ * `<ChartSlot>` in at the position the model placed it. The slot
25
+ * resolves directly to the rendered Echarts visualisation - no
26
+ * skeleton state, because the option is in the same event as the
27
+ * dataset.
35
28
  */
36
29
 
37
- import { randomUUID } from "node:crypto";
38
-
39
- import { logUtils, stringUtils } from "@dbx-tools/appkit-shared";
30
+ import type { MinimalWriter } from "@dbx-tools/appkit-mastra-shared";
31
+ import { commonUtils, logUtils, stringUtils } from "@dbx-tools/shared";
40
32
  import { Agent } from "@mastra/core/agent";
41
33
  import type { RequestContext } from "@mastra/core/request-context";
42
34
  import { createTool } from "@mastra/core/tools";
@@ -44,6 +36,7 @@ import { z } from "zod";
44
36
 
45
37
  import type { MastraPluginConfig } from "./config.js";
46
38
  import { ModelTier, modelForTier, buildModel } from "./model.js";
39
+ import { safeWrite } from "./writer.js";
47
40
 
48
41
  /**
49
42
  * Module-level logger tagged `[mastra/chart]`. Uses the shared
@@ -54,6 +47,79 @@ import { ModelTier, modelForTier, buildModel } from "./model.js";
54
47
  */
55
48
  const log = logUtils.logger("mastra/chart");
56
49
 
50
+ /**
51
+ * One series data point. Wide variant set so the planner agent can
52
+ * faithfully pass through whatever the SQL row set contained
53
+ * (numbers, stringified numbers, nulls for missing measurements,
54
+ * `[x, y]` tuples for scatter, `{name, value}` slices for pie)
55
+ * without the structured-output guard rejecting the whole plan.
56
+ *
57
+ * Three layers of tolerance:
58
+ *
59
+ * 1. {@link z.preprocess} normalizes wire shapes BEFORE union
60
+ * dispatch: stringified numbers parse to numbers, finite
61
+ * checks reject `NaN` / `Infinity`, 2-element arrays coerce
62
+ * tuple components, and `{value}` objects with missing /
63
+ * stringified `value` get coerced or rejected uniformly.
64
+ * Anything not handleable becomes `null`.
65
+ * 2. The union accepts `null` as a first-class variant. Echarts
66
+ * renders null as a gap on bar / line / area (which is the
67
+ * right visual signal for "missing reading"). Scatter and
68
+ * pie filter nulls in {@link planToEchartsOption} because
69
+ * Echarts crashes on null tuples / slices.
70
+ * 3. {@link z.union#catch} backstops the whole thing: if
71
+ * preprocess somehow produces a shape that still doesn't
72
+ * match any variant, the bad item becomes `null` instead of
73
+ * taking down the entire chart with a
74
+ * `Structured output validation failed` error.
75
+ *
76
+ * Net effect: a 200-row dataset with a few sparse/null/string
77
+ * values still produces a chart; only a totally-malformed planner
78
+ * response (no items at all) falls through to the table fallback.
79
+ */
80
+ const chartDataPointSchema = z
81
+ .preprocess(
82
+ (v) => {
83
+ if (v === null || v === undefined) return null;
84
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
85
+ if (typeof v === "string") {
86
+ const n = Number(v);
87
+ return Number.isFinite(n) ? n : null;
88
+ }
89
+ if (Array.isArray(v) && v.length === 2) {
90
+ const x = typeof v[0] === "number" ? v[0] : Number(v[0]);
91
+ const y = typeof v[1] === "number" ? v[1] : Number(v[1]);
92
+ return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
93
+ }
94
+ if (typeof v === "object" && v !== null && "value" in v) {
95
+ const obj = v as { name?: unknown; value: unknown };
96
+ const val = typeof obj.value === "number" ? obj.value : Number(obj.value);
97
+ if (!Number.isFinite(val)) return null;
98
+ // Coerce numeric / boolean / nullish names to strings so a
99
+ // pie slice keyed on a year (`2024`) or category id is
100
+ // accepted without round-tripping through the catch arm.
101
+ const rawName = obj.name;
102
+ const name =
103
+ typeof rawName === "string"
104
+ ? rawName
105
+ : rawName == null
106
+ ? ""
107
+ : String(rawName);
108
+ return { name, value: val };
109
+ }
110
+ return null;
111
+ },
112
+ z.union([
113
+ z.number(),
114
+ z.null(),
115
+ z.tuple([z.number(), z.number()]),
116
+ z.object({ name: z.string(), value: z.number() }),
117
+ ]),
118
+ )
119
+ .catch(null);
120
+
121
+ type ChartDataPoint = z.infer<typeof chartDataPointSchema>;
122
+
57
123
  /**
58
124
  * Compact, model-friendly representation of an Echarts spec. The
59
125
  * planner agent emits this; {@link planToEchartsOption} expands it
@@ -65,8 +131,7 @@ const log = logUtils.logger("mastra/chart");
65
131
  * across charts.
66
132
  */
67
133
  const chartPlanSchema = z.object({
68
- chartType: z
69
- .enum(["bar", "line", "area", "scatter", "pie"])
134
+ chartType: z.enum(["bar", "line", "area", "scatter", "pie"])
70
135
  .describe(stringUtils.toDescription`
71
136
  The chart shape that best matches the data and intent. Use
72
137
  \`bar\` for category-vs-value comparisons, \`line\` for
@@ -86,10 +151,7 @@ const chartPlanSchema = z.object({
86
151
  Axis label to the left of the chart. Used for bar / line /
87
152
  area / scatter; ignored for pie.
88
153
  `),
89
- categories: z
90
- .array(z.string())
91
- .optional()
92
- .describe(stringUtils.toDescription`
154
+ categories: z.array(z.string()).optional().describe(stringUtils.toDescription`
93
155
  X-axis category labels for \`bar\` / \`line\` / \`area\`
94
156
  charts (one per data point in each series). Omit for
95
157
  \`scatter\` (uses [x, y] tuples) and \`pie\` (each slice
@@ -101,18 +163,7 @@ const chartPlanSchema = z.object({
101
163
  name: z.string().describe(stringUtils.toDescription`
102
164
  Legend name for this series.
103
165
  `),
104
- data: z
105
- .array(
106
- z.union([
107
- z.number(),
108
- z.tuple([z.number(), z.number()]),
109
- z.object({
110
- name: z.string(),
111
- value: z.number(),
112
- }),
113
- ]),
114
- )
115
- .describe(stringUtils.toDescription`
166
+ data: z.array(chartDataPointSchema).describe(stringUtils.toDescription`
116
167
  Data points. For \`bar\` / \`line\` / \`area\`, an
117
168
  array of numbers aligned to \`categories\`. For
118
169
  \`scatter\`, an array of \`[x, y]\` numeric tuples.
@@ -120,8 +171,7 @@ const chartPlanSchema = z.object({
120
171
  `),
121
172
  }),
122
173
  )
123
- .min(1)
124
- .describe(stringUtils.toDescription`
174
+ .min(1).describe(stringUtils.toDescription`
125
175
  One or more series to plot. Pie charts use exactly one
126
176
  series; bar/line/area can stack multiple series sharing
127
177
  the same \`categories\` axis.
@@ -200,6 +250,12 @@ export interface RunChartPlannerOptions {
200
250
  title: string;
201
251
  description?: string;
202
252
  data: ReadonlyArray<Record<string, unknown>>;
253
+ /**
254
+ * Cooperative cancellation. Forwarded to the planner agent's
255
+ * `generate({ abortSignal })` call so concurrent renders can be
256
+ * aborted as a group when the parent Genie agent's signal fires.
257
+ */
258
+ signal?: AbortSignal;
203
259
  }
204
260
 
205
261
  /** Output of {@link runChartPlanner}: a fully-formed Echarts spec. */
@@ -226,15 +282,16 @@ function getPlannerAgent(config: MastraPluginConfig): Agent {
226
282
 
227
283
  /**
228
284
  * Run the chart planner against the given dataset and return a
229
- * full Echarts `EChartsOption` JSON. Used by
230
- * {@link emitChartWithPlanning}; tools and producers shouldn't
231
- * call this directly (use the helper instead so chart events
232
- * follow the same wire-format contract everywhere).
285
+ * full Echarts `EChartsOption` JSON. Pure async function: no
286
+ * writer side-effects, no id minting, no background work.
287
+ * Producers (the `render_data` tool, the Genie agent,
288
+ * anything else that needs a chart) await this and stitch the
289
+ * result into whatever shape their wire contract needs.
233
290
  */
234
291
  export async function runChartPlanner(
235
292
  opts: RunChartPlannerOptions,
236
293
  ): Promise<RunChartPlannerResult> {
237
- const { config, requestContext, title, description, data } = opts;
294
+ const { config, requestContext, title, description, data, signal } = opts;
238
295
  const planner = getPlannerAgent(config);
239
296
 
240
297
  const prompt = [
@@ -252,176 +309,13 @@ export async function runChartPlanner(
252
309
  const result = await planner.generate(prompt, {
253
310
  structuredOutput: { schema: chartPlanSchema },
254
311
  ...(requestContext ? { requestContext } : {}),
312
+ ...(signal ? { abortSignal: signal } : {}),
255
313
  });
256
- const plan = result.object;
314
+ const plan = result.object as ChartPlan;
257
315
  const option = planToEchartsOption(plan, title);
258
316
  return { option, chartType: plan.chartType };
259
317
  }
260
318
 
261
- /**
262
- * Minimal `ToolStream`-shaped writer surface. Defined locally so
263
- * helpers can take any object with a `.write` method without
264
- * importing Mastra's full `ToolStream` (which would also drag in
265
- * agent / tool types this module doesn't otherwise need).
266
- */
267
- interface MinimalWriter {
268
- write: (chunk: unknown) => unknown;
269
- }
270
-
271
- /** Inputs to {@link emitChartWithPlanning}. */
272
- export interface EmitChartWithPlanningOptions {
273
- /** Mastra `ctx.writer`; missing or closed writers are tolerated. */
274
- writer?: MinimalWriter;
275
- /** Plugin config; used to resolve the planner's model. */
276
- config: MastraPluginConfig;
277
- /** Per-request context (OBO auth). */
278
- requestContext?: RequestContext;
279
- /** Title shown above the rendered chart. Required. */
280
- title: string;
281
- /** Optional one-line intent biasing the planner. */
282
- description?: string;
283
- /** Tabular dataset to chart (one object per row). */
284
- data: ReadonlyArray<Record<string, unknown>>;
285
- }
286
-
287
- /** Output of {@link emitChartWithPlanning}. */
288
- export interface EmitChartWithPlanningResult {
289
- /** Short id matching the marker `[[chart:<chartId>]]`. */
290
- chartId: string;
291
- /**
292
- * Promise that resolves once the planner has finished and the
293
- * `kind: "chart"` event with the option has been emitted (or
294
- * once the planner has failed silently). Callers that want
295
- * trace observability should `await` this before returning
296
- * from their tool's `execute`; callers that want pure
297
- * fire-and-forget can ignore it.
298
- */
299
- plannerPromise: Promise<void>;
300
- }
301
-
302
- /**
303
- * Shared chart-emission primitive used by both the `render_data`
304
- * tool and Genie's `drainGenieStream`. Keeps both producers on
305
- * one wire-format contract so the chat client only ever has to
306
- * understand a single chart event shape.
307
- *
308
- * Behaviour:
309
- *
310
- * 1. Generates a short `chartId` (8 hex chars).
311
- * 2. Immediately emits `{ kind: "chart", chartId, title,
312
- * description?, data }` via the writer so the chat client can
313
- * mount its `<ChartSlot>` with the rows in hand.
314
- * 3. Kicks off the chart-planner agent in the background. On
315
- * success, emits a second `{ kind: "chart", chartId, option }`
316
- * event - same `chartId`, just the spec - so the client merges
317
- * the two into one rendered chart. On failure, no follow-up
318
- * event fires; the client falls back to whatever it can do
319
- * with the dataset alone (typically a "render failed" frame
320
- * after the parent tool finishes).
321
- *
322
- * Returns `chartId` synchronously so the caller can include it in
323
- * the tool result (model uses it in `[[chart:<chartId>]]`
324
- * markers), and `plannerPromise` so the caller can choose
325
- * trace-spanning vs. snappy-return semantics.
326
- */
327
- export async function emitChartWithPlanning(
328
- opts: EmitChartWithPlanningOptions,
329
- ): Promise<EmitChartWithPlanningResult> {
330
- const { writer, config, requestContext, title, description, data } = opts;
331
-
332
- // Short, marker-friendly id. The LLM types this verbatim into
333
- // `[[chart:<id>]]`; an 8-hex-char prefix is unique within a
334
- // single assistant turn (collision odds ~1 in 4 billion) and
335
- // much less error-prone for the model to reproduce.
336
- const chartId = randomUUID().replace(/-/g, "").slice(0, 8);
337
-
338
- log.debug("emit:start", {
339
- chartId,
340
- title,
341
- rows: data.length,
342
- columns: data[0] ? Object.keys(data[0]) : [],
343
- hasWriter: writer !== undefined,
344
- });
345
-
346
- // Initial event: rows + metadata, no option yet. The client
347
- // mounts a chart slot that shows a skeleton until the option
348
- // event arrives (or until the parent tool finishes without
349
- // one, in which case it falls back).
350
- await safeWrite(writer, chartId, "data", {
351
- kind: "chart",
352
- chartId,
353
- title,
354
- ...(description ? { description } : {}),
355
- data,
356
- });
357
-
358
- // Background planner. Awaitable for trace observability via the
359
- // returned `plannerPromise`; safe to ignore for pure
360
- // fire-and-forget. Failures are intentionally swallowed (only
361
- // logged): the dataset event already landed, so the client has
362
- // enough to surface a fallback.
363
- const plannerPromise = (async () => {
364
- const startedAt = Date.now();
365
- try {
366
- const { option, chartType } = await runChartPlanner({
367
- config,
368
- ...(requestContext ? { requestContext } : {}),
369
- title,
370
- ...(description ? { description } : {}),
371
- data,
372
- });
373
- log.debug("planner:done", {
374
- chartId,
375
- chartType,
376
- elapsedMs: Date.now() - startedAt,
377
- });
378
- await safeWrite(writer, chartId, "option", { kind: "chart", chartId, option });
379
- } catch (err) {
380
- // No follow-up event on failure. The client treats a
381
- // dataset-only chart slot as "render failed" once the
382
- // parent tool's status flips to done. Surface as a `warn`
383
- // so the failure is visible at the default log level
384
- // without being mistaken for a fatal error.
385
- log.warn("planner:error", {
386
- chartId,
387
- elapsedMs: Date.now() - startedAt,
388
- error: err instanceof Error ? err.message : String(err),
389
- });
390
- }
391
- })();
392
-
393
- return { chartId, plannerPromise };
394
- }
395
-
396
- /**
397
- * Best-effort writer.write. Failures are logged at `warn` (a
398
- * persistently-closed writer is the most likely culprit when
399
- * chart events go missing client-side) but swallowed so a closed
400
- * downstream stream (cancelled request, client navigated away)
401
- * can't take a tool down.
402
- */
403
- async function safeWrite(
404
- writer: MinimalWriter | undefined,
405
- chartId: string,
406
- phase: "data" | "option",
407
- chunk: unknown,
408
- ): Promise<void> {
409
- if (!writer) {
410
- log.debug("write:no-writer", { chartId, phase });
411
- return;
412
- }
413
- try {
414
- await writer.write(chunk);
415
- log.debug("write:ok", { chartId, phase });
416
- } catch (err) {
417
- log.warn("write:error", {
418
- chartId,
419
- phase,
420
- error: err instanceof Error ? err.message : String(err),
421
- });
422
- }
423
- }
424
-
425
319
  const renderDataInputSchema = z.object({
426
320
  title: z.string().describe(stringUtils.toDescription`
427
321
  Title shown above the rendered chart. Use a concise
@@ -434,9 +328,7 @@ const renderDataInputSchema = z.object({
434
328
  The chart-planner reads this when picking the chart type and
435
329
  axis encodings; the user does not see it directly.
436
330
  `),
437
- data: z
438
- .array(z.record(z.string(), z.unknown()))
439
- .min(1)
331
+ data: z.array(z.record(z.string(), z.unknown())).min(1)
440
332
  .describe(stringUtils.toDescription`
441
333
  Tabular dataset to chart. One object per row, keyed by
442
334
  column name. Values may be strings, numbers, booleans, or
@@ -458,16 +350,14 @@ const renderDataOutputSchema = z.object({
458
350
  /**
459
351
  * Build the `render_data` tool bound to the given plugin config.
460
352
  *
461
- * The tool is a thin wrapper around {@link emitChartWithPlanning}:
462
- * a single `kind: "chart"` writer event ships the raw rows to
463
- * the client immediately, the chart-planner agent runs alongside
464
- * (so the calling LLM stays unblocked while the planner thinks),
465
- * and a follow-up `kind: "chart"` event with the resolved
466
- * `EChartsOption` lands when it's ready. The tool's `execute`
467
- * awaits the planner promise so the planner work shows up under
468
- * the tool's trace span; the LLM still gets back just
469
- * `{ chartId }`, so its context stays small regardless of dataset
470
- * size.
353
+ * The tool awaits {@link runChartPlanner} so the planner's
354
+ * latency is attributed to this tool's trace span, then emits
355
+ * one `type: "chart"` writer event carrying the dataset and the
356
+ * resolved `EChartsOption`. The LLM-bound output is just
357
+ * `{ chartId }` so the model's context stays flat regardless of
358
+ * dataset size. Planner failures are caught and surfaced as a
359
+ * `type: "error"` writer event so the slot can fall back to
360
+ * "couldn't render chart" without taking the parent agent down.
471
361
  */
472
362
  export function buildRenderDataTool(config: MastraPluginConfig) {
473
363
  return createTool({
@@ -482,14 +372,14 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
482
372
 
483
373
  Placement contract: embed \`[[chart:<chartId>]]\` on its own
484
374
  line (blank lines above and below) wherever you want the
485
- chart to appear in your reply. The client shows a skeleton
486
- at that spot until the chart is ready, then swaps in the
487
- rendered Echarts visualization. You can call
488
- \`render_data\` multiple times in the same turn (the tool
489
- is parallel-safe) and interleave the markers with prose so
490
- each chart sits next to its commentary. A chart whose
491
- marker is omitted falls through to the end of your reply
492
- as a fallback - safe but less polished.
375
+ chart to appear in your reply. The chart is fully resolved
376
+ by the time the tool returns, so it renders immediately at
377
+ that spot. You can call \`render_data\` multiple times in
378
+ the same turn (the tool is parallel-safe) and interleave
379
+ the markers with prose so each chart sits next to its
380
+ commentary. A chart whose marker is omitted falls through
381
+ to the end of your reply as a fallback - safe but less
382
+ polished.
493
383
 
494
384
  Use whenever a SQL row set, API response, or hand-built
495
385
  dataset would land better as a picture than as a list or
@@ -505,18 +395,69 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
505
395
  const writer = (ctx as { writer?: MinimalWriter } | undefined)?.writer;
506
396
  const requestContext = (ctx as { requestContext?: RequestContext } | undefined)
507
397
  ?.requestContext;
508
- const { chartId, plannerPromise } = await emitChartWithPlanning({
509
- ...(writer ? { writer } : {}),
510
- config,
511
- ...(requestContext ? { requestContext } : {}),
398
+
399
+ // Marker-friendly short id. The LLM types this verbatim
400
+ // into `[[chart:<id>]]`; 8 hex chars is unique within a
401
+ // single assistant turn and easy for the model to copy.
402
+ const chartId = commonUtils.shortId();
403
+ const startedAt = Date.now();
404
+ log.debug("render:start", {
405
+ chartId,
512
406
  title,
513
- ...(description ? { description } : {}),
514
- data,
407
+ rows: data.length,
408
+ columns: data[0] ? Object.keys(data[0]) : [],
409
+ hasWriter: writer !== undefined,
515
410
  });
516
- // Await the planner so its latency is attributed to this
517
- // tool's trace span. The promise itself swallows planner
518
- // failures, so this never throws.
519
- await plannerPromise;
411
+
412
+ try {
413
+ const { option, chartType } = await runChartPlanner({
414
+ config,
415
+ ...(requestContext ? { requestContext } : {}),
416
+ title,
417
+ ...(description ? { description } : {}),
418
+ data,
419
+ });
420
+ log.debug("render:done", {
421
+ chartId,
422
+ chartType,
423
+ elapsedMs: Date.now() - startedAt,
424
+ });
425
+ // Single chart event with everything resolved: dataset
426
+ // for the table-like fallback / hover, option for the
427
+ // actual render. Best-effort write so a closed
428
+ // downstream stream can't take the tool down.
429
+ await safeWrite(
430
+ log,
431
+ writer,
432
+ {
433
+ type: "chart",
434
+ chartId,
435
+ title,
436
+ ...(description ? { description } : {}),
437
+ data,
438
+ option,
439
+ },
440
+ { chartId },
441
+ );
442
+ } catch (err) {
443
+ log.warn("render:error", {
444
+ chartId,
445
+ elapsedMs: Date.now() - startedAt,
446
+ error: commonUtils.errorMessage(err),
447
+ });
448
+ // Surface as a writer-level error so the slot can
449
+ // transition to "couldn't render chart" without the
450
+ // parent agent surfacing a stack trace.
451
+ await safeWrite(
452
+ log,
453
+ writer,
454
+ {
455
+ type: "error",
456
+ error: commonUtils.errorMessage(err),
457
+ },
458
+ { chartId },
459
+ );
460
+ }
520
461
  return { chartId };
521
462
  },
522
463
  });
@@ -537,6 +478,14 @@ function planToEchartsOption(
537
478
  const grid = { left: 48, right: 24, top: 56, bottom: 48, containLabel: true };
538
479
 
539
480
  if (plan.chartType === "pie") {
481
+ // Echarts crashes on null pie slices - filter them out.
482
+ // `{name, value}` slices are the only valid pie data shape,
483
+ // so drop bare numbers / tuples / nulls the planner may
484
+ // have leaked into a pie series.
485
+ const slices = (plan.series[0]?.data ?? []).filter(
486
+ (d): d is { name: string; value: number } =>
487
+ d !== null && typeof d === "object" && !Array.isArray(d),
488
+ );
540
489
  return {
541
490
  title: { text: baseTitle, left: "center" },
542
491
  tooltip: { trigger: "item" },
@@ -546,13 +495,16 @@ function planToEchartsOption(
546
495
  name: plan.series[0]?.name ?? baseTitle,
547
496
  type: "pie",
548
497
  radius: ["35%", "65%"],
549
- data: plan.series[0]?.data ?? [],
498
+ data: slices,
550
499
  },
551
500
  ],
552
501
  };
553
502
  }
554
503
 
555
504
  if (plan.chartType === "scatter") {
505
+ // Echarts crashes on null scatter points - keep only valid
506
+ // `[x, y]` tuples. Bare numbers / objects / nulls from a
507
+ // mismatched plan get dropped silently.
556
508
  return {
557
509
  title: { text: baseTitle, left: "center" },
558
510
  tooltip: { trigger: "item" },
@@ -563,7 +515,9 @@ function planToEchartsOption(
563
515
  series: plan.series.map((s) => ({
564
516
  name: s.name,
565
517
  type: "scatter",
566
- data: s.data,
518
+ data: s.data.filter(
519
+ (d): d is [number, number] => Array.isArray(d) && d.length === 2,
520
+ ),
567
521
  })),
568
522
  };
569
523
  }