@dbx-tools/appkit-mastra 0.6.14 → 0.6.27

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
@@ -28,8 +28,8 @@
28
28
  */
29
29
 
30
30
  import { AppKitError, CacheManager, ExecutionError } from "@databricks/appkit";
31
- import { async, error, hash, log, string, type BrandContext } from "@dbx-tools/shared-core";
32
- import { wire, type Chart, type ChartResult } from "@dbx-tools/shared-mastra";
31
+ import { async, error, hash, json, log, string, type BrandContext } from "@dbx-tools/shared-core";
32
+ import { marker, wire, type Chart, type ChartResult } from "@dbx-tools/shared-mastra";
33
33
  import { model } from "@dbx-tools/shared-model";
34
34
  import { Agent } from "@mastra/core/agent";
35
35
  import type { RequestContext } from "@mastra/core/request-context";
@@ -70,22 +70,23 @@ const CHART_FAILED_MESSAGE = "Chart generation failed";
70
70
  * One series data point. Wide variant set so the planner agent can
71
71
  * faithfully pass through whatever the SQL row set contained
72
72
  * (numbers, stringified numbers, nulls for missing measurements,
73
- * `[x, y]` tuples for scatter, `{name, value}` slices for pie)
74
- * without the structured-output guard rejecting the whole plan.
73
+ * `[x, y]` tuples for scatter, `[x, y, value]` triples for heatmap,
74
+ * `{name, value}` slices for pie / funnel / treemap) without the
75
+ * structured-output guard rejecting the whole plan.
75
76
  *
76
77
  * Three layers of tolerance:
77
78
  *
78
79
  * 1. {@link z.preprocess} normalizes wire shapes BEFORE union
79
80
  * dispatch: stringified numbers parse to numbers, finite
80
- * checks reject `NaN` / `Infinity`, 2-element arrays coerce
81
+ * checks reject `NaN` / `Infinity`, 2-/3-element arrays coerce
81
82
  * tuple components, and `{value}` objects with missing /
82
83
  * stringified `value` get coerced or rejected uniformly.
83
84
  * Anything not handleable becomes `null`.
84
85
  * 2. The union accepts `null` as a first-class variant. Echarts
85
86
  * renders null as a gap on bar / line / area (which is the
86
- * right visual signal for "missing reading"). Scatter and
87
- * pie filter nulls in {@link planToEchartsOption} because
88
- * Echarts crashes on null tuples / slices.
87
+ * right visual signal for "missing reading"). Scatter, heatmap,
88
+ * and slice charts filter nulls in {@link planToEchartsOption}
89
+ * because Echarts crashes on null tuples / slices.
89
90
  * 3. {@link z.union#catch} backstops the whole thing: if
90
91
  * preprocess somehow produces a shape that still doesn't
91
92
  * match any variant, the bad item becomes `null` instead of
@@ -101,10 +102,11 @@ const chartDataPointSchema = z
101
102
  const n = Number(v);
102
103
  return Number.isFinite(n) ? n : null;
103
104
  }
104
- if (Array.isArray(v) && v.length === 2) {
105
- const x = typeof v[0] === "number" ? v[0] : Number(v[0]);
106
- const y = typeof v[1] === "number" ? v[1] : Number(v[1]);
107
- return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
105
+ // Scatter `[x, y]` or heatmap `[xIndex, yIndex, value]`. Coerce
106
+ // stringified components; reject if any component is non-finite.
107
+ if (Array.isArray(v) && (v.length === 2 || v.length === 3)) {
108
+ const nums = v.map((c) => (typeof c === "number" ? c : Number(c)));
109
+ return nums.every((n) => Number.isFinite(n)) ? nums : null;
108
110
  }
109
111
  if (typeof v === "object" && v !== null && "value" in v) {
110
112
  const obj = v as { name?: unknown; value: unknown };
@@ -122,20 +124,26 @@ const chartDataPointSchema = z
122
124
  z.union([
123
125
  z.number(),
124
126
  z.null(),
125
- // `[x, y]` scatter point. Modelled as a length-constrained
126
- // homogeneous array rather than `z.tuple`: Zod emits a tuple as
127
- // JSON Schema 2020-12 `prefixItems`, and Databricks' Gemini
128
- // endpoints reject a `response_json_schema` containing it
129
- // ("must be a boolean or an object", since `items` is absent).
130
- // `z.array(z.number()).length(2)` emits `items` + `minItems` /
131
- // `maxItems`, which every provider understands, and validates
132
- // the same two-number shape.
133
- z.array(z.number()).length(2),
127
+ // `[x, y]` scatter or `[x, y, value]` heatmap cell. Modelled as a
128
+ // length-constrained homogeneous array rather than `z.tuple`:
129
+ // Zod emits a tuple as JSON Schema 2020-12 `prefixItems`, and
130
+ // Databricks' Gemini endpoints reject a `response_json_schema`
131
+ // containing it ("must be a boolean or an object", since
132
+ // `items` is absent). `minItems` / `maxItems` + `items` is what
133
+ // every provider understands.
134
+ z.array(z.number()).min(2).max(3),
134
135
  z.object({ name: z.string(), value: z.number() }),
135
136
  ]),
136
137
  )
137
138
  .catch(null);
138
139
 
140
+ /** Per-series mark type used by `combo` charts (bar + line overlay). */
141
+ const seriesMarkTypeSchema = z
142
+ .union([z.literal("bar"), z.literal("line"), z.literal("area")])
143
+ .describe(
144
+ "Mark type for this series. Required for `combo` (mix bar and line/area); ignored for other chart types.",
145
+ );
146
+
139
147
  /**
140
148
  * Compact, model-friendly representation of an Echarts spec. The
141
149
  * planner agent emits this; {@link planToEchartsOption} expands it
@@ -162,8 +170,9 @@ export const chartPlanSchema = z.object({
162
170
  .optional()
163
171
  .describe(
164
172
  string.toDescription(`
165
- Axis label below the chart. Used for bar / line / area / scatter;
166
- ignored for pie.
173
+ Axis label for the primary (usually bottom / value) axis.
174
+ Used for bar / horizontalBar / line / area / combo / waterfall
175
+ / scatter / heatmap; ignored for pie / funnel / treemap / radar.
167
176
  `),
168
177
  ),
169
178
  yAxisLabel: z
@@ -171,8 +180,9 @@ export const chartPlanSchema = z.object({
171
180
  .optional()
172
181
  .describe(
173
182
  string.toDescription(`
174
- Axis label to the left of the chart. Used for bar / line / area /
175
- scatter; ignored for pie.
183
+ Axis label for the secondary (usually left) axis. Used for
184
+ bar / horizontalBar / line / area / combo / waterfall /
185
+ scatter / heatmap; ignored for pie / funnel / treemap / radar.
176
186
  `),
177
187
  ),
178
188
  categories: z
@@ -180,9 +190,22 @@ export const chartPlanSchema = z.object({
180
190
  .optional()
181
191
  .describe(
182
192
  string.toDescription(`
183
- X-axis category labels for \`bar\` / \`line\` / \`area\` charts
184
- (one per data point in each series). Omit for \`scatter\` (uses
185
- [x, y] tuples) and \`pie\` (each slice carries its own \`name\`).
193
+ Primary category labels. For \`bar\` / \`horizontalBar\` /
194
+ \`line\` / \`area\` / \`combo\` / \`waterfall\`: one label per
195
+ data point. For \`heatmap\`: x-axis categories. For \`radar\`:
196
+ indicator names. Omit for \`scatter\` (\`[x, y]\` tuples) and
197
+ slice charts (\`pie\` / \`funnel\` / \`treemap\`, each slice
198
+ carries its own \`name\`).
199
+ `),
200
+ ),
201
+ yCategories: z
202
+ .array(z.string())
203
+ .optional()
204
+ .describe(
205
+ string.toDescription(`
206
+ Y-axis (row) labels for \`heatmap\`. Optional - when omitted the
207
+ row labels come from the series names, which is the preferred
208
+ way to build a heatmap. Omit for every other chart type.
186
209
  `),
187
210
  ),
188
211
  series: z
@@ -193,22 +216,51 @@ export const chartPlanSchema = z.object({
193
216
  Legend name for this series.
194
217
  `),
195
218
  ),
219
+ type: seriesMarkTypeSchema.optional(),
220
+ yAxisIndex: z
221
+ .union([z.literal(0), z.literal(1)])
222
+ .optional()
223
+ .describe(
224
+ "Which y-axis to bind (0 = left, 1 = right). Use on `combo` when series have different units or scales.",
225
+ ),
196
226
  data: z.array(chartDataPointSchema).describe(
197
227
  string.toDescription(`
198
- Data points. For \`bar\` / \`line\` / \`area\`, an array of
199
- numbers aligned to \`categories\`. For \`scatter\`, an array
200
- of \`[x, y]\` numeric tuples. For \`pie\`, an array of
201
- \`{name, value}\` objects.
228
+ Data points. For category charts (\`bar\` / \`horizontalBar\`
229
+ / \`line\` / \`area\` / \`combo\` / \`waterfall\` / \`radar\`),
230
+ an array of numbers aligned to \`categories\`; for
231
+ \`waterfall\` those numbers are signed deltas (one series
232
+ only - never a cumulative base series). For \`heatmap\`,
233
+ one series per matrix row, holding that row's numbers
234
+ aligned to \`categories\`. For \`scatter\`, an array of
235
+ \`[x, y]\` numeric tuples. For \`pie\` / \`funnel\` /
236
+ \`treemap\`, an array of \`{name, value}\` objects.
202
237
  `),
203
238
  ),
204
239
  }),
205
240
  )
206
- .min(1)
241
+ .default([])
207
242
  .describe(
208
243
  string.toDescription(`
209
- One or more series to plot. Pie charts use exactly one series;
210
- bar/line/area can stack multiple series sharing the same
211
- \`categories\` axis.
244
+ One or more series to plot. Required for every chart type
245
+ except \`custom\`, which carries its series inside \`option\`.
246
+ Slice charts (\`pie\` / \`funnel\` / \`treemap\`) and
247
+ \`waterfall\` / \`heatmap\` use exactly one series; \`bar\` /
248
+ \`line\` / \`area\` / \`combo\` / \`radar\` / \`scatter\` can
249
+ carry multiple series.
250
+ `),
251
+ ),
252
+ option: z
253
+ .string()
254
+ .optional()
255
+ .describe(
256
+ string.toDescription(`
257
+ Required for \`custom\`, ignored for every other chart type. A
258
+ COMPLETE Echarts option, as a JSON object encoded in a string:
259
+ \`series\` (each with its own \`type\` and that series' own data
260
+ shape) plus whatever else the chart needs - a coordinate system,
261
+ \`visualMap\`, axes. Plain JSON values only, never a JavaScript
262
+ function. A centered title is filled in when the object omits
263
+ one.
212
264
  `),
213
265
  ),
214
266
  });
@@ -258,6 +310,24 @@ export const chartPlannerRequestSchema = z.object({
258
310
 
259
311
  export type ChartPlannerRequest = z.infer<typeof chartPlannerRequestSchema>;
260
312
 
313
+ /**
314
+ * Agent-facing result of either chart-producing tool.
315
+ *
316
+ * `marker` is deliberately redundant with `chartId`: the host still keys the
317
+ * cache by id, while the model gets the exact opaque token to copy into prose
318
+ * and has no reason to invent or retype a UUID.
319
+ */
320
+ export const chartToolOutputSchema = wire.ChartSchema.pick({ chartId: true }).extend({
321
+ marker: z
322
+ .string()
323
+ .describe(
324
+ "Exact embed marker. Copy this complete value verbatim onto its own line; never construct a marker from chartId.",
325
+ ),
326
+ });
327
+
328
+ /** Result returned synchronously while chart planning continues in the background. */
329
+ export type ChartToolOutput = z.infer<typeof chartToolOutputSchema>;
330
+
261
331
  /* --------------------------- planner instructions --------------------------- */
262
332
 
263
333
  /**
@@ -288,18 +358,58 @@ const CHART_PLANNER_INSTRUCTIONS = string.toDescription(`
288
358
 
289
359
  When in doubt between bar and line, prefer bar for unordered
290
360
  categories and line for ordered ones (dates, time buckets, ranks).
291
- Never pick pie for more than 7 slices.
361
+ Prefer \`horizontalBar\` when category labels are long. Prefer
362
+ \`combo\` when one measure is a count/volume and another is a
363
+ rate/trend. Prefer \`waterfall\` for bridges of signed deltas.
364
+ Prefer \`scatter\` when correlating two numeric fields (no
365
+ category axis). Prefer \`heatmap\` for a category x category
366
+ matrix. Prefer \`radar\` for scoring the same entities across a
367
+ fixed set of dimensions. Never pick pie for more than 7 slices
368
+ (use \`treemap\` instead). Prefer \`funnel\` for ordered conversion
369
+ stages.
370
+
371
+ For bar / horizontalBar / line / area / combo / waterfall: pick one
372
+ column as the category axis (usually the only string-valued column)
373
+ and one or more numeric columns as series. Sort categories by the
374
+ primary series value descending unless the data is naturally ordered
375
+ (dates, ranks, funnel stages, waterfall steps). For \`combo\`, set
376
+ each series' \`type\` to \`bar\`, \`line\`, or \`area\`, and use
377
+ \`yAxisIndex: 1\` when a series needs a second scale.
292
378
 
293
- For bar / line / area: pick one column as the category axis (usually
294
- the only string-valued column) and one or more numeric columns as
295
- series. Sort categories by the primary series value descending unless
296
- the data is naturally ordered (dates, ranks).
379
+ For waterfall, emit exactly ONE series holding the signed step values
380
+ (deltas), one per category, in order - positive for a rise, negative
381
+ for a drop. Do NOT add a cumulative / running-total / base series and
382
+ do NOT convert the deltas into running totals yourself; the running
383
+ total is computed for you, and a hand-built base series renders as a
384
+ plain bar chart instead of a bridge.
297
385
 
298
- For pie: pick the category column for slice names and one numeric
299
- column for slice values. Emit a single series.
386
+ For pie / funnel / treemap: pick the category column for slice names
387
+ and one numeric column for slice values. Emit a single series of
388
+ \`{name, value}\` objects.
300
389
 
301
- For scatter: pick two numeric columns and emit \`[x, y]\` tuples in a
302
- single series.
390
+ For scatter: pick two numeric columns and emit \`[x, y]\` tuples in
391
+ one or more series (one series per group if a grouping column exists).
392
+
393
+ For heatmap: pick two category columns and one numeric measure. Put
394
+ the x-axis categories in \`categories\`, then emit ONE SERIES PER ROW
395
+ of the matrix - the series \`name\` is the row label and \`data\` is
396
+ the row's numbers, one per entry in \`categories\`, in the same order.
397
+ Do not compute cell indices.
398
+
399
+ For radar: \`categories\` are the indicator names; each series is an
400
+ array of numbers (one value per indicator).
401
+
402
+ For anything the types above cannot express - a sankey, boxplot,
403
+ candlestick, sunburst, gauge, network graph, calendar, parallel-
404
+ coordinates plot, or any other Echarts series - use \`custom\` and
405
+ hand-write the whole Echarts option into \`option\` as a JSON string.
406
+ Include every part that chart needs: the series with their own
407
+ \`type\` and data shape, plus any coordinate system, \`visualMap\`,
408
+ or axes. Leave \`series\`, \`categories\`, and the axis labels out;
409
+ they are ignored for \`custom\`. Prefer a listed type whenever one
410
+ genuinely fits - \`custom\` gives up the shared tooltip, legend, and
411
+ grid defaults, so it is the answer for an unsupported chart shape,
412
+ not a way to restyle a supported one.
303
413
 
304
414
  Keep series names human-readable (use the column name; title case it
305
415
  lightly if needed). Keep titles concise; do not repeat the user's
@@ -466,14 +576,14 @@ export interface PrepareChartOptions {
466
576
  * - on planner success: `{ chartId, result }`
467
577
  * - on data / planner failure: `{ chartId, error }`
468
578
  */
469
- export async function prepareChart(opts: PrepareChartOptions): Promise<{ chartId: string }> {
579
+ export async function prepareChart(opts: PrepareChartOptions): Promise<ChartToolOutput> {
470
580
  const chartId = hash.id();
471
581
  await writeChart({ chartId }, opts.userKey);
472
582
  logger.debug("queued", { chartId });
473
583
  // Fire-and-forget. Failures land in the cache as `error` entries;
474
584
  // never escape into an unhandled rejection.
475
585
  void runPrepareChart(chartId, opts);
476
- return { chartId };
586
+ return { chartId, marker: marker.formatMarker("chart", chartId) };
477
587
  }
478
588
 
479
589
  async function runPrepareChart(chartId: string, opts: PrepareChartOptions): Promise<void> {
@@ -575,13 +685,21 @@ export async function fetchChart(
575
685
 
576
686
  /**
577
687
  * The slice of an Echarts option that carries brand identity: the series
578
- * color cycle and the base text style (font family + foreground color).
579
- * Derived from a {@link BrandContext} by {@link brandChartTheme} and merged
580
- * into every spec by {@link planToEchartsOption}.
688
+ * color cycle and the base font stack. Derived from a {@link BrandContext}
689
+ * by {@link brandChartTheme} and merged into every spec by
690
+ * {@link planToEchartsOption}.
691
+ *
692
+ * Deliberately carries no text COLOR. A spec is planned here, on the
693
+ * server, and read later in a browser whose light/dark theme this code
694
+ * cannot know; baking in the brand's single (light) foreground produced
695
+ * near-black labels that disappeared against a dark chat surface. The
696
+ * renderer resolves chrome colors from AppKit's live CSS tokens instead
697
+ * (`@dbx-tools/ui-mastra`'s `chart-theme` + `normalizeChartOption`),
698
+ * leaving this theme the parts that read the same in either mode.
581
699
  */
582
700
  interface ChartTheme {
583
701
  color: string[];
584
- textStyle: { fontFamily: string; color: string };
702
+ textStyle: { fontFamily: string };
585
703
  }
586
704
 
587
705
  /**
@@ -623,18 +741,172 @@ function brandColorCycle(primary: string, accent: string): string[] {
623
741
 
624
742
  /**
625
743
  * Derive an Echarts {@link ChartTheme} from a brand context: the primary +
626
- * accent colors seed the series cycle, the sans stack becomes the base font,
627
- * and the brand foreground becomes the base text color.
744
+ * accent colors seed the series cycle and the sans stack becomes the base
745
+ * font. Text colors are the renderer's job - see {@link ChartTheme}.
628
746
  */
629
747
  function brandChartTheme(brand: BrandContext): ChartTheme {
630
748
  return {
631
749
  color: brandColorCycle(brand.colors.primary, brand.colors.accent),
632
- textStyle: { fontFamily: brand.typography.sans, color: brand.colors.foreground },
750
+ textStyle: { fontFamily: brand.typography.sans },
633
751
  };
634
752
  }
635
753
 
636
754
  /* ----------------------------- echarts expansion ----------------------------- */
637
755
 
756
+ type NamedSlice = { name: string; value: number };
757
+ type ScatterPoint = [number, number];
758
+ type HeatmapCell = [number, number, number];
759
+
760
+ /** Keep only `{name, value}` slices; drop nulls / bare numbers / tuples. */
761
+ function namedSlices(data: ChartPlan["series"][number]["data"]): NamedSlice[] {
762
+ return data.filter(
763
+ (d): d is NamedSlice => d !== null && typeof d === "object" && !Array.isArray(d),
764
+ );
765
+ }
766
+
767
+ /** Keep only finite numbers (category / radar / waterfall series). */
768
+ function numericPoints(data: ChartPlan["series"][number]["data"]): number[] {
769
+ return data.filter((d): d is number => typeof d === "number" && Number.isFinite(d));
770
+ }
771
+
772
+ /** Keep only `[x, y]` scatter tuples. */
773
+ function scatterPoints(data: ChartPlan["series"][number]["data"]): ScatterPoint[] {
774
+ return data.filter(
775
+ (d): d is ScatterPoint => Array.isArray(d) && d.length === 2,
776
+ ) as ScatterPoint[];
777
+ }
778
+
779
+ /** Keep only `[xIndex, yIndex, value]` heatmap cells. */
780
+ function heatmapCells(data: ChartPlan["series"][number]["data"]): HeatmapCell[] {
781
+ return data.filter((d): d is HeatmapCell => Array.isArray(d) && d.length === 3) as HeatmapCell[];
782
+ }
783
+
784
+ /**
785
+ * Resolve a heatmap's cells and row labels from either shape the
786
+ * planner may produce.
787
+ *
788
+ * The documented shape is ONE series of `[xIndex, yIndex, value]`
789
+ * triples, but index arithmetic is exactly the kind of bookkeeping a
790
+ * fast model gets wrong, and the failure is silent (every cell is
791
+ * dropped and the grid renders empty). So the row-per-series shape it
792
+ * already produces reliably for bar charts - one series per matrix
793
+ * row, numbers aligned to `categories` - is accepted too and turned
794
+ * into triples here.
795
+ *
796
+ * Row order reads top-to-bottom: Echarts' category y-axis counts index
797
+ * 0 from the BOTTOM, so a row-derived matrix reverses both the axis
798
+ * labels and the row indices, putting the first series at the top the
799
+ * way the model listed it.
800
+ */
801
+ function heatmapMatrix(plan: ChartPlan): { cells: HeatmapCell[]; yCategories: string[] } {
802
+ const triples = heatmapCells(plan.series[0]?.data ?? []);
803
+ if (triples.length > 0) {
804
+ return { cells: triples, yCategories: plan.yCategories ?? [] };
805
+ }
806
+ const rows = plan.series.map((s) => ({ name: s.name, values: numericPoints(s.data) }));
807
+ const labels = plan.yCategories ?? rows.map((r) => r.name);
808
+ const lastRow = rows.length - 1;
809
+ return {
810
+ cells: rows.flatMap((row, rowIndex) =>
811
+ row.values.map((value, columnIndex): HeatmapCell => [columnIndex, lastRow - rowIndex, value]),
812
+ ),
813
+ yCategories: [...labels].reverse(),
814
+ };
815
+ }
816
+
817
+ /** Sequential light-to-brand ramp for heatmap intensity. */
818
+ const HEATMAP_RAMP = ["#EAF1FE", "#9CBDF7", "#4C86EE", "#2463EB", "#14387F"];
819
+
820
+ /** Above this many cells, printed values overlap and are suppressed. */
821
+ const HEATMAP_LABEL_MAX_CELLS = 60;
822
+
823
+ /** Rising step fill (emerald) and falling step fill (rose). */
824
+ const WATERFALL_INCREASE_COLOR = "#2EB88A";
825
+ const WATERFALL_DECREASE_COLOR = "#DD2C4D";
826
+
827
+ /** Series names the waterfall expansion emits. */
828
+ const WATERFALL_HELPER_NAME = "Running total";
829
+ const WATERFALL_INCREASE_NAME = "Increase";
830
+ const WATERFALL_DECREASE_NAME = "Decrease";
831
+
832
+ /**
833
+ * A model asked for a bridge often builds the running total itself and
834
+ * hands back a cumulative-base series alongside the deltas - which
835
+ * renders as two plain bar series, not a waterfall (the base bars are
836
+ * the tall ones). The base is recognizable by name, so it is dropped
837
+ * here and the running total recomputed from the deltas.
838
+ */
839
+ const WATERFALL_BASE_NAME = /\b(base|cumulative|running|helper|total|start(ing)?)\b/i;
840
+
841
+ /**
842
+ * Pick the signed-delta series out of a waterfall plan, ignoring any
843
+ * cumulative-base series the planner built by hand
844
+ * ({@link WATERFALL_BASE_NAME}). Falls back to the first series when
845
+ * every name looks like a base, since dropping them all would leave
846
+ * nothing to plot.
847
+ */
848
+ function waterfallDeltaSeries(
849
+ series: ChartPlan["series"],
850
+ ): ChartPlan["series"][number] | undefined {
851
+ return series.find((s) => !WATERFALL_BASE_NAME.test(s.name)) ?? series[0];
852
+ }
853
+
854
+ /**
855
+ * Build the stacked transparent-base + increase + decrease series that
856
+ * Echarts uses for a waterfall (it has no native waterfall type).
857
+ * Values are signed deltas; the transparent helper carries the running
858
+ * total so each visible bar starts where the previous one ended.
859
+ *
860
+ * The helper is `silent`, so with an item-triggered tooltip it is
861
+ * invisible to both the eye and the pointer - the alternative, an axis
862
+ * tooltip that hides the helper row, needs a function formatter, and
863
+ * this spec has to survive JSON serialization to the browser.
864
+ */
865
+ function waterfallSeries(values: number[]): Array<Record<string, unknown>> {
866
+ const helpers: number[] = [];
867
+ const increases: Array<number | "-"> = [];
868
+ const decreases: Array<number | "-"> = [];
869
+ let cumulative = 0;
870
+ for (const value of values) {
871
+ if (value >= 0) {
872
+ helpers.push(cumulative);
873
+ increases.push(value);
874
+ decreases.push("-");
875
+ } else {
876
+ helpers.push(cumulative + value);
877
+ increases.push("-");
878
+ decreases.push(-value);
879
+ }
880
+ cumulative += value;
881
+ }
882
+ const transparent = { borderColor: "transparent", color: "transparent" };
883
+ return [
884
+ {
885
+ name: WATERFALL_HELPER_NAME,
886
+ type: "bar",
887
+ stack: "total",
888
+ silent: true,
889
+ itemStyle: transparent,
890
+ emphasis: { itemStyle: transparent },
891
+ data: helpers,
892
+ },
893
+ {
894
+ name: WATERFALL_INCREASE_NAME,
895
+ type: "bar",
896
+ stack: "total",
897
+ itemStyle: { color: WATERFALL_INCREASE_COLOR },
898
+ data: increases,
899
+ },
900
+ {
901
+ name: WATERFALL_DECREASE_NAME,
902
+ type: "bar",
903
+ stack: "total",
904
+ itemStyle: { color: WATERFALL_DECREASE_COLOR },
905
+ data: decreases,
906
+ },
907
+ ];
908
+ }
909
+
638
910
  /**
639
911
  * Expand a {@link ChartPlan} into a full Echarts `EChartsOption`
640
912
  * JSON. Centralized here so the planner agent only fills in the
@@ -646,7 +918,7 @@ function brandChartTheme(brand: BrandContext): ChartTheme {
646
918
  * color cycle and base text style (see {@link brandChartTheme}); otherwise
647
919
  * Echarts' defaults apply.
648
920
  */
649
- function planToEchartsOption(
921
+ export function planToEchartsOption(
650
922
  plan: ChartPlan,
651
923
  fallbackTitle: string,
652
924
  brand?: BrandContext,
@@ -656,25 +928,41 @@ function planToEchartsOption(
656
928
  const theme = brand ? brandChartTheme(brand) : undefined;
657
929
  const themed = (option: Record<string, unknown>): Record<string, unknown> =>
658
930
  theme ? { ...theme, ...option } : option;
931
+ const title = { text: baseTitle, left: "center" };
932
+ const legend = { bottom: 0 };
659
933
 
660
- if (plan.chartType === "pie") {
661
- // Echarts crashes on null pie slices - filter them out.
662
- // `{name, value}` slices are the only valid pie data shape,
663
- // so drop bare numbers / tuples / nulls the planner may
664
- // have leaked into a pie series.
665
- const slices = (plan.series[0]?.data ?? []).filter(
666
- (d): d is { name: string; value: number } =>
667
- d !== null && typeof d === "object" && !Array.isArray(d),
668
- );
934
+ if (plan.chartType === "custom") {
935
+ // The option arrives as a JSON STRING, not a nested object: this plan is
936
+ // the planner's provider-enforced structured output, and a free-form
937
+ // object becomes an unconstrained `additionalProperties` schema that
938
+ // strict OpenAI and Gemini endpoints reject - which would break EVERY
939
+ // chart, not just this one (same class of hazard as the `prefixItems`
940
+ // note on `chartDataPointSchema`). A malformed string costs one chart.
941
+ // Nothing here is eval'd; a string-valued Echarts formatter is a
942
+ // template, not code.
943
+ const option = json.parseRecord(plan.option);
944
+ if (!option) {
945
+ throw new Error('chartType "custom" needs an `option` holding a JSON object');
946
+ }
947
+ // Title is the one default worth filling: every other branch guarantees
948
+ // one and the renderer reserves grid space for it. A title the model set
949
+ // wins, as does every other field it declared.
950
+ return themed({ title, ...option });
951
+ }
952
+
953
+ if (plan.chartType === "pie" || plan.chartType === "funnel" || plan.chartType === "treemap") {
954
+ const slices = namedSlices(plan.series[0]?.data ?? []);
955
+ const seriesType = plan.chartType;
669
956
  return themed({
670
- title: { text: baseTitle, left: "center" },
957
+ title,
671
958
  tooltip: { trigger: "item" },
672
- legend: { bottom: 0 },
959
+ legend: seriesType === "treemap" ? undefined : legend,
673
960
  series: [
674
961
  {
675
962
  name: plan.series[0]?.name ?? baseTitle,
676
- type: "pie",
677
- radius: ["35%", "65%"],
963
+ type: seriesType,
964
+ ...(seriesType === "pie" ? { radius: ["35%", "65%"] } : {}),
965
+ ...(seriesType === "funnel" ? { sort: "descending" } : {}),
678
966
  data: slices,
679
967
  },
680
968
  ],
@@ -686,27 +974,175 @@ function planToEchartsOption(
686
974
  // `[x, y]` tuples. Bare numbers / objects / nulls from a
687
975
  // mismatched plan get dropped silently.
688
976
  return themed({
689
- title: { text: baseTitle, left: "center" },
977
+ title,
690
978
  tooltip: { trigger: "item" },
691
- legend: { bottom: 0 },
979
+ legend,
692
980
  grid,
693
981
  xAxis: { type: "value", name: plan.xAxisLabel },
694
982
  yAxis: { type: "value", name: plan.yAxisLabel },
695
983
  series: plan.series.map((s) => ({
696
984
  name: s.name,
697
985
  type: "scatter",
698
- data: s.data.filter((d): d is [number, number] => Array.isArray(d) && d.length === 2),
986
+ data: scatterPoints(s.data),
699
987
  })),
700
988
  });
701
989
  }
702
990
 
991
+ if (plan.chartType === "heatmap") {
992
+ const { cells, yCategories } = heatmapMatrix(plan);
993
+ const values = cells.map((c) => c[2]);
994
+ const min = values.length > 0 ? Math.min(...values) : 0;
995
+ const max = values.length > 0 ? Math.max(...values) : 1;
996
+ return themed({
997
+ title,
998
+ tooltip: { position: "top" },
999
+ // The visualMap ramp sits below the plot, so the grid gives up
1000
+ // more bottom room than an axis-only chart needs.
1001
+ grid: { ...grid, bottom: 72 },
1002
+ xAxis: {
1003
+ type: "category",
1004
+ data: plan.categories ?? [],
1005
+ name: plan.xAxisLabel,
1006
+ splitArea: { show: true },
1007
+ },
1008
+ yAxis: {
1009
+ type: "category",
1010
+ data: yCategories,
1011
+ name: plan.yAxisLabel,
1012
+ splitArea: { show: true },
1013
+ },
1014
+ visualMap: {
1015
+ // Echarts hides every cell when min === max (a uniform matrix),
1016
+ // so a degenerate range is widened by one.
1017
+ min,
1018
+ max: max > min ? max : min + 1,
1019
+ calculable: true,
1020
+ orient: "horizontal",
1021
+ left: "center",
1022
+ bottom: 8,
1023
+ inRange: { color: HEATMAP_RAMP },
1024
+ },
1025
+ series: [
1026
+ {
1027
+ name: plan.series[0]?.name ?? baseTitle,
1028
+ type: "heatmap",
1029
+ data: cells,
1030
+ // Printed cell values are unreadable once the grid is dense.
1031
+ label: { show: cells.length <= HEATMAP_LABEL_MAX_CELLS },
1032
+ emphasis: { itemStyle: { shadowBlur: 10, shadowColor: "rgba(0, 0, 0, 0.3)" } },
1033
+ },
1034
+ ],
1035
+ });
1036
+ }
1037
+
1038
+ if (plan.chartType === "radar") {
1039
+ const indicators = (plan.categories ?? []).map((name, index) => {
1040
+ const peak = Math.max(
1041
+ 0,
1042
+ ...plan.series.map((s) => {
1043
+ const nums = numericPoints(s.data);
1044
+ return nums[index] ?? 0;
1045
+ }),
1046
+ );
1047
+ return { name, max: peak > 0 ? peak : 1 };
1048
+ });
1049
+ return themed({
1050
+ title,
1051
+ tooltip: { trigger: "item" },
1052
+ legend,
1053
+ radar: { indicator: indicators },
1054
+ series: [
1055
+ {
1056
+ type: "radar",
1057
+ data: plan.series.map((s) => ({
1058
+ name: s.name,
1059
+ value: numericPoints(s.data),
1060
+ })),
1061
+ },
1062
+ ],
1063
+ });
1064
+ }
1065
+
1066
+ if (plan.chartType === "waterfall") {
1067
+ const values = numericPoints(waterfallDeltaSeries(plan.series)?.data ?? []);
1068
+ return themed({
1069
+ title,
1070
+ // Item-triggered so the transparent `silent` offset bars never
1071
+ // surface in a tooltip (see `waterfallSeries`).
1072
+ tooltip: { trigger: "item" },
1073
+ // Only the two visible steps belong in the legend.
1074
+ legend: { ...legend, data: [WATERFALL_INCREASE_NAME, WATERFALL_DECREASE_NAME] },
1075
+ grid,
1076
+ xAxis: {
1077
+ type: "category",
1078
+ data: plan.categories ?? [],
1079
+ name: plan.xAxisLabel,
1080
+ },
1081
+ yAxis: { type: "value", name: plan.yAxisLabel },
1082
+ series: waterfallSeries(values),
1083
+ });
1084
+ }
1085
+
1086
+ if (plan.chartType === "horizontalBar") {
1087
+ return themed({
1088
+ title,
1089
+ tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
1090
+ legend,
1091
+ grid,
1092
+ xAxis: { type: "value", name: plan.xAxisLabel },
1093
+ yAxis: {
1094
+ type: "category",
1095
+ data: plan.categories ?? [],
1096
+ name: plan.yAxisLabel,
1097
+ },
1098
+ series: plan.series.map((s) => ({
1099
+ name: s.name,
1100
+ type: "bar",
1101
+ data: s.data,
1102
+ })),
1103
+ });
1104
+ }
1105
+
1106
+ if (plan.chartType === "combo") {
1107
+ const usesRightAxis = plan.series.some((s) => s.yAxisIndex === 1);
1108
+ return themed({
1109
+ title,
1110
+ tooltip: { trigger: "axis" },
1111
+ legend,
1112
+ grid,
1113
+ xAxis: {
1114
+ type: "category",
1115
+ data: plan.categories ?? [],
1116
+ name: plan.xAxisLabel,
1117
+ },
1118
+ yAxis: usesRightAxis
1119
+ ? [
1120
+ { type: "value", name: plan.yAxisLabel },
1121
+ { type: "value", name: undefined },
1122
+ ]
1123
+ : { type: "value", name: plan.yAxisLabel },
1124
+ series: plan.series.map((s) => {
1125
+ const mark = s.type ?? "bar";
1126
+ const seriesType = mark === "area" ? "line" : mark;
1127
+ return {
1128
+ name: s.name,
1129
+ type: seriesType,
1130
+ data: s.data,
1131
+ yAxisIndex: s.yAxisIndex ?? 0,
1132
+ smooth: seriesType === "line",
1133
+ ...(mark === "area" ? { areaStyle: {} } : {}),
1134
+ };
1135
+ }),
1136
+ });
1137
+ }
1138
+
703
1139
  // bar / line / area share the same axis layout.
704
1140
  const isArea = plan.chartType === "area";
705
1141
  const seriesType = plan.chartType === "bar" ? "bar" : "line";
706
1142
  return themed({
707
- title: { text: baseTitle, left: "center" },
1143
+ title,
708
1144
  tooltip: { trigger: "axis" },
709
- legend: { bottom: 0 },
1145
+ legend,
710
1146
  grid,
711
1147
  xAxis: {
712
1148
  type: "category",
@@ -735,9 +1171,8 @@ function planToEchartsOption(
735
1171
  * Thin wrapper over {@link prepareChart} for callers that already
736
1172
  * have a dataset in hand. Mints a `chartId` synchronously, caches
737
1173
  * an empty placeholder, and kicks off the chart-planner in the
738
- * background. Returns just the `chartId`; the host UI resolves
739
- * `[chart:<chartId>]` markers by hitting the plugin's
740
- * `/embed/chart/:id` route.
1174
+ * background. Returns the `chartId` plus its ready-to-copy `marker`; the host
1175
+ * UI resolves that marker through the plugin's `/embed/chart/:id` route.
741
1176
  *
742
1177
  * For Genie statement results, prefer the Genie agent's
743
1178
  * `prepare_chart` tool, which accepts a `statement_id` and
@@ -751,14 +1186,15 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
751
1186
  Submit a tabular dataset for inline rendering as a chart in
752
1187
  the user's view. Pass a title, the raw rows (array of objects
753
1188
  keyed by column name), and an optional one-line description
754
- of the insight to highlight. Returns a short \`chartId\`;
755
- the chart renders inline at the position you embed the
756
- matching \`[chart:<chartId>]\` marker.
1189
+ of the insight to highlight. Returns \`chartId\` plus the
1190
+ complete \`marker\`; copy the returned marker VERBATIM onto
1191
+ its own line where the chart should render. Never construct,
1192
+ alter, or invent a chart marker yourself.
757
1193
  `,
758
1194
  `
759
- Placement contract: embed \`[chart:<chartId>]\` on its own
760
- line (blank lines above and below) wherever you want the
761
- chart to appear in your reply. The chart resolves
1195
+ Placement contract: embed the returned \`marker\` on its own
1196
+ line (blank lines above and below) wherever you want the chart
1197
+ to appear in your reply. The chart resolves
762
1198
  asynchronously - the tool returns the id immediately and the
763
1199
  host UI fetches the chart from the cache once the planner
764
1200
  lands. You can call \`render_data\` multiple times in the
@@ -774,7 +1210,7 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
774
1210
  `,
775
1211
  ]),
776
1212
  inputSchema: chartPlannerRequestSchema,
777
- outputSchema: wire.ChartSchema.pick({ chartId: true }),
1213
+ outputSchema: chartToolOutputSchema,
778
1214
  execute: async (input, ctxRaw) => {
779
1215
  const { title, description, data } = input as ChartPlannerRequest;
780
1216
  const ctx = ctxRaw as { requestContext?: RequestContext } | undefined;