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 00000000..d0af2fdb Binary files /dev/null and b/docs/figs/native-echarts-dual-axis.png differ 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',