From edd8c2b7f7dfe1d102d3fcc8df82ea184d47a792 Mon Sep 17 00:00:00 2001 From: windylcx Date: Tue, 29 Sep 2026 18:28:29 +0800 Subject: [PATCH] feat(echarts): chart_spec.echarts, a native option escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The semantic layer covers the common cases; ECharts is larger than any channel set. Until now a chart needing a second value axis, a mixed mark type, markLine/dataZoom, a formatter, or per-series styling had to leave Flint and hand-write the option — losing the layer it came for. chart_spec.echarts patches the compiled option, applied last so it wins over the layout pass: - objects merge recursively; arrays of plain objects (series, xAxis, yAxis) merge element-wise by index, appending past the end, so series[1] is addressable without restating series[0]; other arrays replace; an empty array says nothing; "_" keys are dropped with a warning. - series entries may bind columns instead of carrying data: field / fields add series read from the chart's rows (categoryField picks the pairing column, defaulting to the chart's x field), and axis: "right" puts one on a right-hand value axis that Flint creates when missing. Bound entries are added, unbound entries patch by index. - backend-scoped: the other assemblers ignore the key, as Vega-Lite ignores theme_spec. The MCP tool declares it too: chart_spec there is a zod object, which strips unknown keys, so without the declaration a native patch never reached the assembler. Verified: 18 new flint-chart unit tests pinning the merge contract; 3 flint-mcp tests rendering a column-bound dual-axis chart end to end (SSR → SVG) plus a compile_chart passthrough case; full flint-chart 1671 + flint-mcp 90 tests, typecheck, lint, build and site:build clean; a real PNG rendered from a spec that binds a second metric to a right axis. Guide: docs/native-echarts-options.md (+ zh-CN). --- CHANGELOG.md | 14 + docs/api-reference.md | 4 +- docs/figs/native-echarts-dual-axis.png | Bin 0 -> 57067 bytes docs/native-echarts-options.md | 113 ++++++ docs/zh-CN/native-echarts-options.md | 97 +++++ packages/flint-js/src/core/types.ts | 13 + packages/flint-js/src/echarts/assemble.ts | 11 + .../flint-js/src/echarts/native-option.ts | 334 ++++++++++++++++++ .../tests/native-echarts-option.test.ts | 238 +++++++++++++ packages/flint-mcp/src/tools/schemas.ts | 8 + .../tests/native-echarts-option.test.ts | 68 ++++ packages/flint-mcp/tests/server.test.ts | 34 ++ site/src/i18n/messages/en.json | 6 +- site/src/i18n/messages/zh-CN.json | 6 +- site/src/shared/docs-catalog.ts | 6 + 15 files changed, 949 insertions(+), 3 deletions(-) create mode 100644 docs/figs/native-echarts-dual-axis.png create mode 100644 docs/native-echarts-options.md create mode 100644 docs/zh-CN/native-echarts-options.md create mode 100644 packages/flint-js/src/echarts/native-option.ts create mode 100644 packages/flint-js/tests/native-echarts-option.test.ts create mode 100644 packages/flint-mcp/tests/native-echarts-option.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42439201..b28da829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `chart_spec.echarts`, a native escape hatch for the ECharts capabilities the + semantic layer does not model — a second value axis, mixed mark types, + `markLine`, `dataZoom`, formatters, per-series styling. The object is merged + onto the compiled option last, so it wins over the layout pass: plain objects + merge recursively, arrays of plain objects (`series`, `xAxis`, `yAxis`) merge + element-wise by index with entries past the end appended, any other array + replaces, and `_`-prefixed keys are dropped with an `info` warning. Series + entries may bind columns instead of carrying `data`: `field` / `fields` add + series read from the rows (`categoryField` picks the pairing column, + defaulting to the chart's own x field) and `axis: "right"` puts one on a + right-hand value axis, which Flint creates when the chart has none. Bound + entries are added, unbound entries patch by index. Backend-scoped: the other + assemblers ignore the key the way Vega-Lite ignores `theme_spec`. + Guide: `docs/native-echarts-options.md`. - Viewport changes a gesture commits animate by default. A `navigate` or `brush-zoom` reset flies home, and a brush zoom tweens into the brushed region, over 400 ms on every Vega-Lite chart, not only on projected maps. diff --git a/docs/api-reference.md b/docs/api-reference.md index ef29541f..facb5ff1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -30,7 +30,7 @@ Conceptual background: [Overview](/documentation/overview) · Pipeline: [Archite |---------------|-----------|----------| | Raw table | `data` | `{ values: rows[] }` or `{ url: "..." }` | | **dataSpec** | `semantic_types` | `field → string` or `field → SemanticAnnotation` | -| **chartSpec** | `chart_spec` | `chartType`, `encodings`, `canvasSize`, `chartProperties` | +| **chartSpec** | `chart_spec` | `chartType`, `encodings`, `canvasSize`, `chartProperties`, `echarts` | Author `semantic_types` once per dataset and reuse it across charts. During most exploration, `chart_spec` is the only part that changes. @@ -122,6 +122,7 @@ interface ChartAssemblyInput { baseSize?: { width: number; height: number }; // target layout size, default 400×320 canvasSize?: { width: number; height: number }; // optional hard ceiling on stretch chartProperties?: Record; + echarts?: Record; }; theme_spec?: ThemeSpec | string; // presentation, Vega-Lite only interaction_spec?: InteractionSpec; // behaviour, Vega-Lite interactive surface only @@ -187,6 +188,7 @@ lists the presets a template supports by declaration. See [Using interactions](/ | `baseSize` | **Target** layout size in pixels (default 400×320): the size the chart aims for with typical data. Dense data may stretch past it, up to the ceiling. | | `canvasSize` | **Hard ceiling:** the maximum size the chart may ever reach, including faceted grids. If omitted, the ceiling is `baseSize × options.maxStretch` (default 1.5×). Per-dimension caps are `βx = canvasSize.width / baseSize.width`, `βy = canvasSize.height / baseSize.height` (each ≥ 1). The base is clamped to the ceiling, so a `canvasSize` on its own acts as a fixed box the chart fills and shrinks to fit without overflowing. | | `chartProperties` | Template-specific toggles (e.g. `orient`, `opacity`) | +| `echarts` | Native ECharts option patch — the escape hatch for capabilities the semantic layer does not model. ECharts backend only; see [Native ECharts options](./native-echarts-options.md). | > **base vs. canvas, in one line:** `baseSize` is what the chart *aims for*; > `canvasSize` is what it *may never exceed*. Use `canvasSize` for a fixed slot, diff --git a/docs/figs/native-echarts-dual-axis.png b/docs/figs/native-echarts-dual-axis.png new file mode 100644 index 0000000000000000000000000000000000000000..d0af2fdbe2a7e30d74e1dddf2e4f8fc9b8d4d91c GIT binary patch literal 57067 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

=!wCh)m6L*0WOg{OExT(xCePpfSgL+q> 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 literal 0 HcmV?d00001 diff --git a/docs/native-echarts-options.md b/docs/native-echarts-options.md new file mode 100644 index 00000000..9b509eb4 --- /dev/null +++ b/docs/native-echarts-options.md @@ -0,0 +1,113 @@ +# Native ECharts options + +Flint's semantic layer covers the common cases. ECharts is much larger than any +channel set: a second value axis, a bar and a line in one chart, `markLine` / +`markArea`, `dataZoom`, `visualMap`, axis and tooltip formatters, per-series +`areaStyle`, custom `grid`s — the list does not end. Without a way to reach +those, a caller whose chart needs one of them has to costndon Flint and +hand-write the option, and loses the semantic layer it came for. + +`chart_spec.echarts` is that way. It is a **native escape hatch, not a second +semantic language**: nothing in it decides anything about the data. It merges +what you state onto the option Flint compiled, and — optionally — binds extra +series to columns so a native series does not mean re-serialising your rows. + +```jsonc +{ + "chartType": "Line Chart", + "encodings": { "x": { "field": "month" }, "y": { "field": "revenue" } }, + "echarts": { + "legend": { "show": true, "top": 6 }, + "yAxis": [{ "name": "Revenue" }, { "name": "Margin", "position": "right" }], + "series": [ + { "name": "Revenue", "lineStyle": { "width": 2 } }, + { "type": "line", "field": "margin", "name": "Margin rate", "axis": "right", "lineStyle": { "type": "dashed" } } + ] + } +} +``` + +That spec compiles to a real dual-axis chart: Flint's own series stays on the +left axis, and the added `margin` series lands on a right-hand axis — no +template support required, and no change to the encodings. + +

+ A line chart with the semantic layer's series on the left axis and a column-bound series on a dashed right axis. +

+ +## Where it goes + +Inside `chart_spec`, beside `encodings` and `chartProperties`. It is +backend-scoped by name and ignored by the other assemblers, the same way +`theme_spec` is Vega-Lite's. Passing it to a Vega-Lite build is harmless and +has no effect. + +The patch is applied **last** — after the layout pass and the template's own +post-processing — so what you state is authoritative over what Flint decided +above it. + +## Merge semantics + +The whole contract is four rules: + +| Patch value | Result | +|---|---| +| Plain object | merged key by key, recursively | +| Array of plain objects (`series`, `xAxis`, `yAxis`, …) | merged **element-wise by index**; entries past the end are appended | +| Any other array (a series' `data`, a `color` list) | **replaces** the compiled value | +| Anything else (scalar, `null`) | replaces | + +An array of plain objects merging by index is what makes a single series +addressable: `"series": [{}, { "lineStyle": { "type": "dashed" } }]` restyles +the second series without restating the first. The base series keeps its +`data`; only what you name changes. + +An empty array says nothing, and never wipes a value Flint just built. + +Keys beginning with `_` are reserved by Flint (`_warnings`, `_pivot`, +`_viewports`, …). They are dropped, with an `info` warning in `_warnings`. + +## Binding a series to columns + +A series entry may carry `field` / `fields` instead of `data`: + +| Key | Meaning | +|---|---| +| `field` | one column → one series; `data` is read from the rows as `[category, value]` pairs | +| `fields` | several columns → one series each, named after the column | +| `categoryField` | which column to pair against (defaults to the chart's own x field) | +| `axis` | `"right"` puts the series on a right-hand value axis | + +Entries that bind a column are **added**; entries that do not are **patched by +index**. Both intents can appear in one array, and the position of an added +series among the patches does not matter — only its order among the additions. + +`"axis": "right"` is sugar: the series gets `yAxisIndex: 1`, a right-hand value +axis is created if the chart does not have one, and the grid reserves room for +it. If you patch `yAxis` yourself, yours wins. + +Binding is skipped — with a warning — when the chart has no category field +(a pie, say: the series then gets plain values) or when the chart is faceted +(one option holds every panel, so "the" category axis is ambiguous; pass +explicit `data` there). + +## What it does not do + +- It does not add channels. `chart_spec.encodings` still only accepts the + channels a template declares; anything beyond that reaches ECharts through + this hatch, not through a new channel. +- It does not validate ECharts' own option vocabulary. An unknown key is + passed through and ECharts ignores it, exactly as it would in a hand-written + option. +- It does not create data. A native series can bind existing columns; it + cannot compute new ones. Aggregate upstream (in SQL, or with + `chartProperties.aggregate`) and bind the result. +- Only the ECharts backend reads it. Chart.js, Plotly, Excel, and Vega-Lite + builds ignore the key. + +## Related + +- [ECharts chart reference](./reference-echarts.md) — channels and + `chartProperties` per chart type. +- [API reference](./api-reference.md) — `ChartAssemblyInput` and + `chart_spec`. diff --git a/docs/zh-CN/native-echarts-options.md b/docs/zh-CN/native-echarts-options.md new file mode 100644 index 00000000..6bb8e98b --- /dev/null +++ b/docs/zh-CN/native-echarts-options.md @@ -0,0 +1,97 @@ +# 原生 ECharts 能力(逃生口) + +Flint 的语义层覆盖的是常见情况,而 ECharts 远大于任何一组通道:第二根数值轴、 +柱线混排、`markLine` / `markArea`、`dataZoom`、`visualMap`、轴与 tooltip 的 +formatter、逐系列 `areaStyle`、自定义 `grid`……列不完。没有出口的话,需要这些 +能力的调用方只能放弃 Flint 手写 option,连带丢掉它本来要用的语义层。 + +`chart_spec.echarts` 就是这个出口。它是**原生逃生口,不是第二套语义语言**:它 +不替你决定任何和数据有关的事,只把你写的东西合并到 Flint 编出来的 option 上; +并且可以选择让新增系列直接绑定数据列,免得为了加一条线重新序列化整个数据集。 + +```jsonc +{ + "chartType": "Line Chart", + "encodings": { "x": { "field": "month" }, "y": { "field": "revenue" } }, + "echarts": { + "legend": { "show": true, "top": 6 }, + "yAxis": [{ "name": "营收" }, { "name": "毛利率", "position": "right" }], + "series": [ + { "name": "营收", "lineStyle": { "width": 2 } }, + { "type": "line", "field": "margin", "name": "毛利率", "axis": "right", "lineStyle": { "type": "dashed" } } + ] + } +} +``` + +上面这段编出来就是一张真正的双轴图:Flint 自己那条线留在左轴,新增的 +`margin` 系列落在右轴——不需要任何模板支持,也不需要改 `encodings`。 + +

+ 语义层的系列在左轴、绑列新增的系列在右侧虚线轴上的折线图。 +

+ +## 放在哪 + +放在 `chart_spec` 里,与 `encodings`、`chartProperties` 同级。它以名字标明后端, +其他装配器会忽略它——和 `theme_spec` 只属于 Vega-Lite 是同一惯例。把它交给 +Vega-Lite 构建是无害的,也不会生效。 + +补丁**最后**应用(在布局计算与模板自身的后处理之后),所以你写的东西优先于 +Flint 在上游做出的任何决定。 + +## 合并语义 + +整个契约就四条: + +| 补丁里的值 | 行为 | +|---|---| +| 普通对象 | 逐键递归合并 | +| 普通对象组成的数组(`series`、`xAxis`、`yAxis` 等) | **按下标逐项**合并;超出基数组长度的条目追加 | +| 其他数组(某系列的 `data`、`color` 列表) | **整体替换** | +| 其他(标量、`null`) | 替换 | + +「对象数组按下标合并」是单条系列可寻址的关键: +`"series": [{}, { "lineStyle": { "type": "dashed" } }]` 就能只改第二条线,不必 +把第一条重抄一遍;基系列保留自己的 `data`,只有你点名的键会变。 + +空数组表示「没有要说的」,绝不会把 Flint 刚构造好的值清空。 + +以 `_` 开头的键是 Flint 保留的(`_warnings`、`_pivot`、`_viewports` 等),会被 +丢弃并在 `_warnings` 里留一条 `info`。 + +## 把系列绑到数据列 + +系列条目可以用 `field` / `fields` 代替 `data`: + +| 键 | 含义 | +|---|---| +| `field` | 一列 → 一条系列;`data` 从数据行读成 `[类目, 数值]` 对 | +| `fields` | 多列 → 各成一条系列,默认以列名命名 | +| `categoryField` | 与哪一列配对(默认取图表自己的 x 字段) | +| `axis` | `"right"` 表示这条系列走右轴 | + +**绑列的条目是「新增」,不绑列的条目是「按下标修补」。** 两种意图可以写在同一个 +数组里;新增系列在补丁里的位置无关紧要,只有新增之间的先后有意义。 + +`"axis": "right"` 是语法糖:该系列拿到 `yAxisIndex: 1`,图表若没有右轴就建一根 +(并把 grid 的右边距留出来)。如果你自己补了 `yAxis`,以你的为准。 + +两种情况下绑列会跳过并给出警告:图表没有类目字段(比如饼图,此时系列拿到纯数值 +数组);图表是分面的(一个 option 装所有面板,「那个」类目轴是有歧义的,请显式给 +`data`)。 + +## 它不做什么 + +- **不增加通道**:`chart_spec.encodings` 仍然只接受模板声明的通道;通道之外的能力 + 走这个出口,而不是新造一个通道。 +- **不校验 ECharts 自己的词表**:没见过的键照传,ECharts 自己忽略它——和手写 + option 一样。 +- **不造数据**:原生系列只能绑已有列,不能算新列。先在 SQL 或 + `chartProperties.aggregate` 里聚合好再绑。 +- **只有 ECharts 后端读它**:Chart.js、Plotly、Excel、Vega-Lite 构建会忽略该键。 + +## 相关 + +- [ECharts 图表参考](./reference-echarts.md)——每种图型的通道与 `chartProperties`。 +- [API 参考](./api-reference.md)——`ChartAssemblyInput` 与 `chart_spec`。 diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 84a23187..3173a0a6 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -1193,6 +1193,19 @@ export interface ChartAssemblyInput { canvasSize?: { width: number; height: number }; /** Template-specific configurable properties (e.g., bar corner radius, show labels) */ chartProperties?: Record; + /** + * Native ECharts option patch — the escape hatch for everything the + * semantic layer does not model (a second value axis, mixed mark + * types, `markLine`, `dataZoom`, formatters, per-series styling, …). + * + * Merged onto the compiled option by the ECharts assembler; see + * `echarts/native-option.ts` for the exact merge semantics and the + * `field` / `fields` / `axis: "right"` series binding sugar. + * + * Backend-scoped by name, and ignored by the other assemblers — the + * same way `theme_spec` is Vega-Lite's. + */ + echarts?: Record; }; /** diff --git a/packages/flint-js/src/echarts/assemble.ts b/packages/flint-js/src/echarts/assemble.ts index 5ddd0f95..60481b31 100644 --- a/packages/flint-js/src/echarts/assemble.ts +++ b/packages/flint-js/src/echarts/assemble.ts @@ -68,6 +68,7 @@ import { toTypeString, type SemanticAnnotation } from '../core/field-semantics'; import { filterOverflow } from '../core/filter-overflow'; import { computeLayout, computeChannelBudgets, deriveStretchCaps, resolveBaseSize, resolveFacetColumnsOption } from '../core/compute-layout'; import { ecApplyLayoutToSpec, ecApplyTooltips } from './instantiate-spec'; +import { applyNativeEChartsOption } from './native-option'; import { ecCombineFacetPanels } from './facet'; import { DEFAULT_COLORS } from './templates/utils'; import { inferVisCategory, computeZeroDecision } from '../core/semantic-types'; @@ -530,6 +531,16 @@ export function assembleECharts(input: ChartAssemblyInput): any { } } + // ── Native escape hatch (chart_spec.echarts) ────────────────────────── + // Applied last so the caller's native patch is authoritative over every + // decision above it — including the layout pass and template postProcess. + applyNativeEChartsOption(ecOption, input.chart_spec.echarts, { + rows: values, + categoryField: encodings.x?.field, + warnings, + faceted: hasFacet, + }); + // ═══════════════════════════════════════════════════════════════════════ // RESULT // ═══════════════════════════════════════════════════════════════════════ diff --git a/packages/flint-js/src/echarts/native-option.ts b/packages/flint-js/src/echarts/native-option.ts new file mode 100644 index 00000000..087f7ba9 --- /dev/null +++ b/packages/flint-js/src/echarts/native-option.ts @@ -0,0 +1,334 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Native ECharts escape hatch — `chart_spec.echarts`. + * + * Flint's semantic layer covers the common cases, but ECharts is much larger + * than any channel set: a second value axis, mixed mark types in one chart, + * `markLine`/`markArea`, `dataZoom`, axis/tooltip formatters, per-series + * `areaStyle`, custom `grid`s, `visualMap` — the list does not end. Without a + * way to reach those, a caller whose chart needs one of them has to costndon + * Flint and hand-write the option, losing the semantic layer it came for. + * + * This module is the deliberate seam: the caller patches *the compiled + * ECharts option* directly. It is a native escape hatch, not a second + * semantic language — nothing here decides anything about the data; it only + * merges what the caller states, and (optionally) binds extra series to + * columns so a native series does not require re-serialising the rows. + * + * Shape (`chart_spec.echarts`): + * + * ```jsonc + * { + * "yAxis": [{ "type": "value" }, { "type": "value", "position": "right" }], + * "series": [ + * { "type": "line", "field": "margin", "name": "Margin rate", "axis": "right" } + * ], + * "tooltip": { "valueFormatter": "{value}%" } + * } + * ``` + * + * Merge semantics (documented because they are the whole contract): + * - plain objects merge key by key, recursively; + * - arrays **of plain objects** (`series`, `xAxis`, `yAxis`, …) merge + * element-wise by index, and entries past the end of the base are + * appended — so `series[1].lineStyle` is addressable without restating + * `series[0]`; + * - any other array (a series' `data`, a `color` list) **replaces** the + * base value; an empty array says nothing and is skipped; + * - anything else (scalar, `null`) replaces. + * + * Series entries may carry `field` / `fields` instead of `data`: the assembler + * materialises `[category, value]` pairs from the chart's rows, using the + * chart's own category field unless `categoryField` says otherwise. + * `axis: "right"` is sugar for "put this series on a right-hand value axis", + * which is created if the chart does not already have one. + * + * Which of the two things a `series` entry means is decided by binding, not by + * position: an entry that binds a column is a **new series** (appended), an + * entry that does not is a **patch of the series at its own index**. Both may + * appear in one array. + * + * Private (`_`-prefixed) keys are stripped: they carry Flint's own annotations + * (`_warnings`, `_pivot`, `_viewports`, …) and a caller has no business + * writing them. + */ + +import type { ChartWarning } from '../core/types'; + +/** A native ECharts series that may bind its data to columns. */ +export interface NativeEChartsSeries { + [key: string]: unknown; + /** One measure column → one series. */ + field?: string; + /** Several measure columns → one series each (name defaults to the column). */ + fields?: string[]; + /** Category column for `field` binding (defaults to the chart's x field). */ + categoryField?: string; + /** `"right"` puts the series on a right-hand value axis. */ + axis?: 'left' | 'right'; +} + +/** Right-hand axis margin added to `grid.right` when we create the axis. */ +export const RIGHT_AXIS_EXTRA_MARGIN = 56; + +/** Codes surfaced through `_warnings`. */ +export const NATIVE_ECHARTS_CODES = { + invalid: 'native_echarts_invalid', + privateKeys: 'native_echarts_private_keys', + unboundSeries: 'native_echarts_unbound_series', + unknownField: 'native_echarts_unknown_field', + faceted: 'native_echarts_faceted', +} as const; + +type PlainObject = Record; + +function isPlainObject(value: unknown): value is PlainObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function clone(value: T): T { + if (Array.isArray(value)) return value.map((entry) => clone(entry)) as unknown as T; + if (isPlainObject(value)) { + const out: PlainObject = {}; + for (const [key, entry] of Object.entries(value)) out[key] = clone(entry); + return out as unknown as T; + } + return value; +} + +/** Every element (in either array) is a plain object → addressable by index. */ +function isObjectArray(value: unknown): value is PlainObject[] { + return Array.isArray(value) && value.length > 0 && value.every((entry) => isPlainObject(entry) && !Array.isArray(entry)); +} + +/** + * Merge `patch` into `base` in place, following the semantics documented at + * the top of this module. Returns `base`. + */ +export function mergeNativeOption(base: Record, patch: Record): Record { + for (const [key, value] of Object.entries(patch)) { + if (key.startsWith('_')) continue; + const current = base[key]; + if (isPlainObject(current) && isPlainObject(value)) { + mergeNativeOption(current as Record, value); + continue; + } + if (isObjectArray(current) && isObjectArray(value)) { + for (let i = 0; i < value.length; i++) { + const entry = value[i]; + const target = (current as unknown[])[i]; + if (isPlainObject(target)) { + mergeNativeOption(target as Record, entry); + } else { + (current as unknown[])[i] = clone(entry); + } + } + continue; + } + // An empty array says nothing — it must not wipe a value the chart + // just built (an empty `series` patch would delete every series). + if (Array.isArray(value) && value.length === 0) continue; + base[key] = clone(value); + } + return base; +} + +/** Fields a native series entry uses for Flint-side data binding, not for ECharts. */ +const BINDING_KEYS = ['field', 'fields', 'categoryField', 'axis'] as const; + +function stripBindingKeys(series: NativeEChartsSeries, name: string): PlainObject { + const out: PlainObject = {}; + for (const [key, value] of Object.entries(series)) { + if ((BINDING_KEYS as readonly string[]).includes(key) || key === 'name') continue; + out[key] = clone(value); + } + if (name) out.name = name; + return out; +} + +export interface NativeEChartsContext { + /** Rows the chart was built from (post-filter, same rows the series saw). */ + rows: Record[]; + /** The chart's own x/category field, used when a series does not name one. */ + categoryField?: string; + warnings: ChartWarning[]; + /** Faceted charts combine panels into one option; per-series binding is ambiguous there. */ + faceted: boolean; +} + +/** + * Split the `series` entries of a native patch into the two things a caller + * can mean by "series": + * + * - an entry that **binds a column** (`field` / `fields`) is a *new* series: + * its data is materialised from the chart's rows and it is appended; + * - an entry that does not bind a column is a *patch* of the series at its + * own index — which is what makes `series[1].lineStyle` reachable without + * restating `series[0]`. + * + * Both intents can appear in one array; the position of an added series among + * the patches does not matter, only its order among the additions does. + */ +function splitSeries( + entries: unknown[], + ctx: NativeEChartsContext, +): { patches: unknown[]; additions: unknown[]; rightAxisNeeded: boolean } { + const patches: unknown[] = []; + const additions: unknown[] = []; + let rightAxisNeeded = false; + + const buildData = (field: string, categoryField: string): unknown[] | undefined => { + const sample = ctx.rows[0]; + if (sample && !(field in sample)) { + ctx.warnings.push({ + severity: 'warning', + code: NATIVE_ECHARTS_CODES.unknownField, + message: `chart_spec.echarts.series[].field "${field}" does not exist in data.values`, + field, + }); + return undefined; + } + if (!categoryField) return ctx.rows.map((row) => row[field]); + return ctx.rows.map((row) => [row[categoryField], row[field]]); + }; + + for (const entry of entries) { + if (!isPlainObject(entry)) { + // Not an object → nothing to bind and nothing to patch into; drop. + ctx.warnings.push({ + severity: 'warning', + code: NATIVE_ECHARTS_CODES.invalid, + message: 'chart_spec.echarts.series[] entries must be objects; a non-object entry was ignored.', + }); + continue; + } + const series = entry as NativeEChartsSeries; + const fields = typeof series.field === 'string' + ? [series.field] + : Array.isArray(series.fields) + ? series.fields.filter((f): f is string => typeof f === 'string') + : []; + + if (fields.length === 0) { + if (series.axis === 'right') { + ctx.warnings.push({ + severity: 'warning', + code: NATIVE_ECHARTS_CODES.unboundSeries, + message: 'chart_spec.echarts.series[] sets axis "right" without field/fields; the series is patched in place instead of added.', + }); + } + patches.push(clone(entry)); + continue; + } + if (ctx.faceted) { + ctx.warnings.push({ + severity: 'warning', + code: NATIVE_ECHARTS_CODES.faceted, + message: 'chart_spec.echarts.series[].field binding is ignored for faceted charts; pass explicit data instead.', + }); + patches.push(clone(entry)); + continue; + } + + const categoryField = typeof series.categoryField === 'string' + ? series.categoryField + : (ctx.categoryField ?? ''); + + for (const field of fields) { + const data = buildData(field, categoryField); + if (data === undefined) continue; + const name = fields.length > 1 ? field : (typeof series.name === 'string' ? series.name : ''); + const built: PlainObject = { ...stripBindingKeys(series, name), data }; + if (series.axis === 'right') { + rightAxisNeeded = true; + if (built.yAxisIndex === undefined) built.yAxisIndex = 1; + } + additions.push(built); + } + } + + return { patches, additions, rightAxisNeeded }; +} + +/** + * Make sure the option has value axes for every `yAxisIndex` a native series + * asked for: axes past the first are appended as right-hand axes, and the + * grid reserves room for them. A caller that patches `yAxis`/`grid` itself + * still wins — this only fills in what is missing. + */ +function ensureValueAxes(option: Record, rightAxisNeeded: boolean): void { + if (!rightAxisNeeded) return; + const existing = option.yAxis; + const axes: unknown[] = Array.isArray(existing) ? existing : existing === undefined ? [] : [existing]; + if (axes.length < 2) axes.push({ type: 'value', position: 'right' }); + option.yAxis = axes; + const grid = option.grid; + if (isPlainObject(grid) && typeof grid.right === 'number') { + grid.right = grid.right + RIGHT_AXIS_EXTRA_MARGIN; + } +} + +/** + * Apply `chart_spec.echarts` to a compiled option. + * + * Mutates `option`; returns the same object. Warnings land in `ctx.warnings` + * (surfaced as the compiled spec's `_warnings`). + */ +export function applyNativeEChartsOption( + option: Record, + native: unknown, + ctx: NativeEChartsContext, +): Record { + if (native === undefined || native === null) return option; + if (!isPlainObject(native)) { + ctx.warnings.push({ + severity: 'warning', + code: NATIVE_ECHARTS_CODES.invalid, + message: 'chart_spec.echarts must be an object; it was ignored.', + }); + return option; + } + + const patch = clone(native) as PlainObject; + const privateKeys = Object.keys(patch).filter((key) => key.startsWith('_')); + for (const key of privateKeys) delete patch[key]; + if (privateKeys.length > 0) { + ctx.warnings.push({ + severity: 'info', + code: NATIVE_ECHARTS_CODES.privateKeys, + message: `chart_spec.echarts keys starting with "_" are reserved by Flint and were ignored: ${privateKeys.join(', ')}.`, + }); + } + + // `series` is handled here rather than by the generic merge: a bound entry + // is an *addition*, an unbound entry is a *patch by index*, and one array + // cannot be merged as if it were only one of those. See splitSeries. + const seriesEntries = Array.isArray(patch.series) + ? patch.series + : isPlainObject(patch.series) + ? [patch.series] + : undefined; + delete patch.series; + + let additions: unknown[] = []; + let rightAxisNeeded = false; + if (seriesEntries) { + const split = splitSeries(seriesEntries, ctx); + additions = split.additions; + rightAxisNeeded = split.rightAxisNeeded; + if (split.patches.length > 0) patch.series = split.patches; + } + + ensureValueAxes(option, rightAxisNeeded); + mergeNativeOption(option, patch); + + if (additions.length > 0) { + const current = option.series; + const base: unknown[] = Array.isArray(current) ? current : current === undefined ? [] : [current]; + base.push(...additions); + option.series = base; + } + return option; +} diff --git a/packages/flint-js/tests/native-echarts-option.test.ts b/packages/flint-js/tests/native-echarts-option.test.ts new file mode 100644 index 00000000..4d0987ac --- /dev/null +++ b/packages/flint-js/tests/native-echarts-option.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * `chart_spec.echarts` — the native ECharts escape hatch. + * + * The tests pin the *contract* (merge semantics + binding sugar), because the + * whole value of the hatch rests on it being predictable: a caller who patches + * `series[1]` must not have to restate `series[0]`, a caller who passes raw + * `data` must get raw `data`, and a caller who asks for a right-hand axis must + * get a real second axis without touching any template. + */ + +import { describe, it, expect } from 'vitest'; +import { assembleECharts } from '../src/echarts'; +import { assembleVegaLite, validateChart } from '../src'; +import type { ChartAssemblyInput } from '../src/core/types'; + +const ROWS = [ + { month: '2026-01', revenue: 120, margin: 0.18, cost: 0.11 }, + { month: '2026-02', revenue: 96, margin: 0.22, cost: 0.14 }, + { month: '2026-03', revenue: 141, margin: 0.15, cost: 0.09 }, +]; + +const SEMANTIC_TYPES = { month: 'DateTime', revenue: 'Quantity', margin: 'Percentage', cost: 'Percentage' }; + +function lineInput(echarts?: Record, encodings?: Record): ChartAssemblyInput { + return { + data: { values: ROWS }, + semantic_types: SEMANTIC_TYPES, + chart_spec: { + chartType: 'Line Chart', + encodings: (encodings ?? { x: { field: 'month' }, y: { field: 'revenue' } }) as any, + baseSize: { width: 600, height: 400 }, + ...(echarts ? { echarts } : {}), + }, + } as ChartAssemblyInput; +} + +const warningsOf = (spec: any) => (spec._warnings ?? []) as Array<{ code: string; severity: string }>; + +describe('chart_spec.echarts — native option merge', () => { + it('deep-merges objects and keeps the base values it does not mention', () => { + const spec = assembleECharts(lineInput({ + tooltip: { valueFormatter: '{value}%' }, + yAxis: { axisLabel: { formatter: '{value}' } }, + dataZoom: [{ type: 'inside' }], + })) as any; + + expect(spec.tooltip.trigger).toBe('axis'); // base kept + expect(spec.tooltip.valueFormatter).toBe('{value}%'); // patch applied + expect(spec.yAxis.name).toBe('revenue'); // base kept + expect(spec.yAxis.axisLabel.formatter).toBe('{value}'); + expect(spec.dataZoom).toEqual([{ type: 'inside' }]); // new top-level key + }); + + it('merges series element-wise by index, so series[1] is addressable without restating series[0]', () => { + const spec = assembleECharts(lineInput({ + series: [{ lineStyle: { width: 5 } }, { type: 'line', name: 'Margin', field: 'margin' }], + })) as any; + + expect(spec.series).toHaveLength(2); + expect(spec.series[0].lineStyle).toEqual({ width: 5 }); + expect(spec.series[0].data).toHaveLength(ROWS.length); // base data intact + expect(spec.series[1].name).toBe('Margin'); + expect(spec.series[1].data).toEqual([ + ['2026-01', 0.18], + ['2026-02', 0.22], + ['2026-03', 0.15], + ]); + }); + + it('replaces (not merges) primitive arrays such as a series\' data', () => { + const spec = assembleECharts(lineInput({ + series: [{ data: [['2026-01', 9]] }], + })) as any; + + expect(spec.series[0].data).toEqual([['2026-01', 9]]); + }); + + it('expands fields[] into one series per column, named after the column', () => { + const spec = assembleECharts(lineInput({ + series: [{ type: 'line', fields: ['margin', 'cost'] }], + })) as any; + + expect(spec.series).toHaveLength(3); + expect(spec.series[1].name).toBe('margin'); + expect(spec.series[2].name).toBe('cost'); + expect(spec.series[2].data[0]).toEqual(['2026-01', 0.11]); + }); + + it('honours an explicit categoryField', () => { + const spec = assembleECharts(lineInput({ + series: [{ type: 'line', field: 'margin', categoryField: 'month', name: 'Margin' }], + })) as any; + + expect(spec.series[1].data.map((p: unknown[]) => p[0])).toEqual(['2026-01', '2026-02', '2026-03']); + }); + + it('drops the binding keys from the compiled series (they are Flint-side, not ECharts)', () => { + const spec = assembleECharts(lineInput({ + series: [{ type: 'bar', field: 'margin', categoryField: 'month', axis: 'right', name: 'Margin' }], + })) as any; + + const s = spec.series[1]; + expect(s.field).toBeUndefined(); + expect(s.fields).toBeUndefined(); + expect(s.categoryField).toBeUndefined(); + expect(s.axis).toBeUndefined(); + expect(s.type).toBe('bar'); + }); +}); + +describe('chart_spec.echarts — dual axis without a template change', () => { + it('creates a right-hand value axis, binds the series to it, and reserves grid room', () => { + const base = assembleECharts(lineInput()) as any; + const spec = assembleECharts(lineInput({ + series: [{ type: 'line', field: 'margin', name: 'Margin', axis: 'right' }], + })) as any; + + expect(Array.isArray(spec.yAxis)).toBe(true); + expect(spec.yAxis).toHaveLength(2); + expect(spec.yAxis[1]).toMatchObject({ type: 'value', position: 'right' }); + expect(spec.series[1].yAxisIndex).toBe(1); + expect(spec.grid.right).toBeGreaterThan(base.grid.right); + }); + + it('keeps a caller-supplied yAxis when one is patched in', () => { + const spec = assembleECharts(lineInput({ + series: [{ type: 'line', field: 'margin', axis: 'right' }], + yAxis: [{}, { name: 'Right axis' }], + })) as any; + + expect(spec.yAxis).toHaveLength(2); + expect(spec.yAxis[0].name).toBe('revenue'); // base axis untouched + expect(spec.yAxis[1]).toMatchObject({ name: 'Right axis', position: 'right' }); + }); + + it('does not create a second axis when the series stays on the left', () => { + const spec = assembleECharts(lineInput({ + series: [{ type: 'line', field: 'margin' }], + })) as any; + + expect(Array.isArray(spec.yAxis)).toBe(false); + expect(spec.series[1].yAxisIndex).toBeUndefined(); + }); +}); + +describe('chart_spec.echarts — guards', () => { + it('ignores a non-object payload with a warning', () => { + const spec = assembleECharts(lineInput('nope' as any)) as any; + + expect(spec.series).toHaveLength(1); + expect(warningsOf(spec).map((w) => w.code)).toContain('native_echarts_invalid'); + }); + + it('strips Flint-reserved "_" keys with an info warning', () => { + const spec = assembleECharts(lineInput({ _width: 1, tooltip: { show: true } })) as any; + + expect(spec._width).toBeTypeOf('number'); + expect(spec._width).not.toBe(1); + expect(spec.tooltip.show).toBe(true); + const warning = warningsOf(spec).find((w) => w.code === 'native_echarts_private_keys'); + expect(warning?.severity).toBe('info'); + }); + + it('warns and skips a series bound to a column that does not exist', () => { + const spec = assembleECharts(lineInput({ + series: [{ type: 'line', field: 'nope' }], + })) as any; + + expect(spec.series).toHaveLength(1); + const warning = warningsOf(spec).find((w) => w.code === 'native_echarts_unknown_field'); + expect(warning).toBeDefined(); + }); + + it('leaves the native patch applied but refuses per-series binding on faceted charts', () => { + const spec = assembleECharts(lineInput( + { series: [{ type: 'line', field: 'margin' }], legend: { show: false } }, + { x: { field: 'month' }, y: { field: 'revenue' }, column: { field: 'margin' } }, + )) as any; + + expect(warningsOf(spec).map((w) => w.code)).toContain('native_echarts_faceted'); + expect(spec.legend.show).toBe(false); + }); + + it('falls back to plain values when the chart has no category field', () => { + const spec = assembleECharts({ + data: { values: ROWS }, + semantic_types: SEMANTIC_TYPES, + chart_spec: { + chartType: 'Pie Chart', + encodings: { color: { field: 'month' }, size: { field: 'revenue' } }, + baseSize: { width: 400, height: 320 }, + echarts: { series: [{ type: 'bar', field: 'margin', name: 'Margin' }] }, + }, + } as any) as any; + + const native = spec.series[spec.series.length - 1]; + expect(native.name).toBe('Margin'); + expect(native.data).toEqual([0.18, 0.22, 0.15]); + }); + + it('is ignored by other backends (backend-scoped, like theme_spec)', () => { + const input = lineInput({ series: [{ type: 'line', field: 'margin' }] }) as any; + const spec = assembleVegaLite(input) as any; + + expect(spec.encoding).toBeDefined(); + expect(JSON.stringify(spec)).not.toContain('"field":"margin"'); + }); + + it('passes validation: a native patch never turns into an assembly failure', () => { + const ok = validateChart(lineInput({ tooltip: { show: false } }), 'echarts'); + expect(ok.errors).toEqual([]); + expect(ok.valid).toBe(true); + expect(ok.computedSize).toBeDefined(); + + // A malformed payload degrades to a warning — the chart still renders. + const bad = validateChart(lineInput('nope' as any), 'echarts'); + expect(bad.valid).toBe(true); + expect(bad.warnings.map((w) => w.code)).toContain('native_echarts_invalid'); + }); + + it('an empty array in the patch does not wipe a value the chart built', () => { + const spec = assembleECharts(lineInput({ series: [] })) as any; + + expect(spec.series).toHaveLength(1); + expect(spec.series[0].data).toHaveLength(ROWS.length); + }); + + it('leaves the caller\'s own input object untouched', () => { + const payload = { series: [{ type: 'line', field: 'margin', name: 'Margin' }] }; + const input = lineInput(payload); + assembleECharts(input); + + expect(payload.series).toEqual([{ type: 'line', field: 'margin', name: 'Margin' }]); + }); +}); diff --git a/packages/flint-mcp/src/tools/schemas.ts b/packages/flint-mcp/src/tools/schemas.ts index ca205ae2..29783ce2 100644 --- a/packages/flint-mcp/src/tools/schemas.ts +++ b/packages/flint-mcp/src/tools/schemas.ts @@ -85,6 +85,12 @@ export const chartSpecSchema = z .record(z.string(), z.any()) .optional() .describe('Template-specific properties (e.g. bar corner radius, show labels).'), + echarts: z + .record(z.string(), z.any()) + .optional() + .describe( + 'Native ECharts option patch, for the ECharts backend only — the escape hatch for what the semantic layer does not model (a second value axis, mixed mark types, markLine, dataZoom, formatters, per-series styling). Merged onto the compiled option last: objects merge recursively; arrays of objects (series, xAxis, yAxis) merge element-wise by index with extra entries appended; other arrays replace. A series entry may bind a column instead of carrying data: { type: "line", field: "margin", name: "Margin", axis: "right" } adds that series on a right-hand axis (created if missing); entries without field/fields patch the series at their index. Guide: https://microsoft.github.io/flint-chart/#/documentation/native-echarts-options', + ), }) .describe('What to draw.'); @@ -156,6 +162,8 @@ export type AssemblyInputArgs = { baseSize?: { width: number; height: number }; canvasSize?: { width: number; height: number }; chartProperties?: Record; + /** Native ECharts option patch (ECharts backend only) — see the schema's description. */ + echarts?: Record; }; options?: Record; theme_spec?: string | Record; diff --git a/packages/flint-mcp/tests/native-echarts-option.test.ts b/packages/flint-mcp/tests/native-echarts-option.test.ts new file mode 100644 index 00000000..ac24e267 --- /dev/null +++ b/packages/flint-mcp/tests/native-echarts-option.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import type { ChartAssemblyInput } from 'flint-chart'; +import { renderChart } from '../src/render/index.js'; + +/** + * The native ECharts escape hatch, end to end through the renderer. + * + * The flint-chart unit tests pin the compile-time contract; this one pins the + * thing a caller actually depends on — that a native patch survives the MCP + * render path (compile → SSR → SVG) and reaches ECharts, including the + * column-bound series and the axis Flint creates for it. + */ +const sales: ChartAssemblyInput = { + data: { + values: [ + { month: '2026-01', revenue: 120, margin: 0.18 }, + { month: '2026-02', revenue: 96, margin: 0.22 }, + { month: '2026-03', revenue: 141, margin: 0.15 }, + { month: '2026-04', revenue: 110, margin: 0.19 }, + ], + }, + semantic_types: { month: 'DateTime', revenue: 'Quantity', margin: 'Percentage' }, + chart_spec: { + chartType: 'Line Chart', + title: 'Revenue with margin on a second axis', + encodings: { x: { field: 'month' }, y: { field: 'revenue' } }, + baseSize: { width: 480, height: 320 }, + echarts: { + legend: { show: true, top: 6 }, + yAxis: [{ name: 'Revenue' }, { name: 'Margin', position: 'right' }], + series: [ + { name: 'Revenue' }, + { type: 'line', field: 'margin', name: 'Margin rate', axis: 'right', lineStyle: { type: 'dashed' } }, + ], + }, + }, +}; + +describe('renderChart — chart_spec.echarts', () => { + it('renders the column-bound series, its right axis, and the native legend', async () => { + const res = await renderChart(sales, 'echarts', { format: 'svg' }); + const svg = res.svg ?? ''; + + expect(svg).toContain('Revenue'); + expect(svg).toContain('Margin rate'); + expect(svg).toContain('Margin'); + // The dashed line is the native series' own style, not a template default. + expect(svg).toContain('stroke-dasharray'); + }); + + it('reports no warnings for that spec', async () => { + const res = await renderChart(sales, 'echarts', { format: 'svg' }); + expect(res.warnings).toEqual([]); + }); + + it('still renders when the chart is built by the semantic layer alone', async () => { + const plain: ChartAssemblyInput = { + ...sales, + chart_spec: { ...sales.chart_spec, echarts: undefined } as ChartAssemblyInput['chart_spec'], + }; + const res = await renderChart(plain, 'echarts', { format: 'svg' }); + + // The layer's own axis title (the raw field) and none of the native additions. + expect(res.svg).toContain('revenue'); + expect(res.svg).not.toContain('Margin rate'); + expect(res.svg).not.toContain('Margin'); + }); +}); diff --git a/packages/flint-mcp/tests/server.test.ts b/packages/flint-mcp/tests/server.test.ts index 14f514b0..92f4864d 100644 --- a/packages/flint-mcp/tests/server.test.ts +++ b/packages/flint-mcp/tests/server.test.ts @@ -99,6 +99,40 @@ describe('MCP server', () => { expect(Array.isArray(payload.warnings)).toBe(true); }); + it('compile_chart passes chart_spec.echarts through to the assembler', async () => { + // The tool input is a zod object, and zod strips unknown keys — a native + // patch that is not declared in the schema never reaches ECharts. + const res: any = await client.callTool({ + name: 'compile_chart', + arguments: { + ...barChart, + backend: 'echarts', + data: { + values: [ + { region: 'North', revenue: 120, target: 100 }, + { region: 'South', revenue: 90, target: 105 }, + { region: 'East', revenue: 150, target: 110 }, + ], + }, + semantic_types: { region: 'Category', revenue: 'Quantity', target: 'Quantity' }, + chart_spec: { + ...barChart.chart_spec, + echarts: { + yAxis: [{ name: 'Revenue' }, { name: 'Target', position: 'right' }], + series: [{ type: 'line', field: 'target', name: 'Target', axis: 'right' }], + }, + }, + }, + }); + expect(res.isError).toBeFalsy(); + const payload = JSON.parse(res.content[0].text); + const spec = payload.spec; + expect(Array.isArray(spec.yAxis)).toBe(true); + expect(spec.yAxis[1].position).toBe('right'); + expect(spec.yAxis[0].name).toBe('Revenue'); + expect(spec.series.some((s: any) => s.name === 'Target' && s.yAxisIndex === 1)).toBe(true); + }); + it('validate_chart flags an unknown chart type as invalid', async () => { const res: any = await client.callTool({ name: 'validate_chart', diff --git a/site/src/i18n/messages/en.json b/site/src/i18n/messages/en.json index 0b1c9b08..b179f08c 100644 --- a/site/src/i18n/messages/en.json +++ b/site/src/i18n/messages/en.json @@ -39,7 +39,7 @@ "demoAria": "Demo themes", "inspiration": "Need inspiration?", "demos": { - "google-material": "Google Material", + "google-material": "Google Material", "microsoft-fluent": "Microsoft Fluent", "peoples-daily": "People's Daily" }, @@ -174,6 +174,10 @@ "adding-a-chart-template": { "title": "Extending chart templates", "description": "Add a chart type to an existing backend, then cover it in tests and the gallery." + }, + "native-echarts-options": { + "title": "Native ECharts options", + "description": "chart_spec.echarts: the escape hatch for the ECharts capabilities the semantic layer does not model." } } }, diff --git a/site/src/i18n/messages/zh-CN.json b/site/src/i18n/messages/zh-CN.json index cf965fd2..5c2cb1b6 100644 --- a/site/src/i18n/messages/zh-CN.json +++ b/site/src/i18n/messages/zh-CN.json @@ -39,7 +39,7 @@ "demoAria": "示例主题", "inspiration": "需要灵感?", "demos": { - "google-material": "Google Material", + "google-material": "Google Material", "microsoft-fluent": "Microsoft Fluent", "peoples-daily": "人民日报" }, @@ -174,6 +174,10 @@ "adding-a-chart-template": { "title": "扩展图表模板", "description": "为已有后端新增图表类型,并覆盖测试与图表示例。" + }, + "native-echarts-options": { + "title": "原生 ECharts 能力", + "description": "chart_spec.echarts:语义层未建模的 ECharts 能力的逃生口——原生 option 合并与数据列绑定。" } } }, diff --git a/site/src/shared/docs-catalog.ts b/site/src/shared/docs-catalog.ts index ac999049..8acde042 100644 --- a/site/src/shared/docs-catalog.ts +++ b/site/src/shared/docs-catalog.ts @@ -130,6 +130,12 @@ export const DOCUMENTATION_GROUPS: DocGroup[] = [ description: 'Every ECharts chart type, its channels, and configurable parameters.', file: '../../../docs/reference-echarts.md', }, + { + slug: 'native-echarts-options', + title: 'Native ECharts options', + description: 'chart_spec.echarts: the escape hatch for the ECharts capabilities the semantic layer does not model.', + file: '../../../docs/native-echarts-options.md', + }, { slug: 'reference-chartjs', title: 'Chart.js charts',