diff --git a/.github/release-notes/0.3.0.md b/.github/release-notes/0.3.0.md
new file mode 100644
index 00000000..5945c154
--- /dev/null
+++ b/.github/release-notes/0.3.0.md
@@ -0,0 +1,43 @@
+# Flint 0.3
+
+Flint 0.3 makes charts easier to explore after they are created. The MCP App can
+now switch between compatible chart types and edit chart properties in place,
+while keeping the original Flint spec compact and readable.
+
+## Explore chart types
+
+Move between compatible views without asking the agent to rebuild the chart.
+Flint preserves the data roles and derives the details needed by the new chart
+type.
+
+
+
+## Edit chart properties
+
+Common chart properties are available directly in the widget. Changes are
+immediate, and the available controls follow the selected chart and backend.
+
+
+
+## Flint in Chinese
+
+The website and documentation are now available in Chinese, including guides for
+getting started, authoring charts, and extending Flint.
+
+
+
+## Smaller updates
+
+- Added backend-neutral chart recommendations and chart transformations.
+- Improved sparse area charts, grouped bars, boxplots, Gantt charts, and
+ Waterfall charts across supported backends.
+- Improved semantic formatting for axes, aggregates, and Sparkline averages.
+- Sparklines now self-scale each row and no longer expose a shared-Y option.
+- Added PNG copy, download, and reset actions to the MCP App.
+- Expanded chart-property guidance in the bundled agent skill and reference
+ documentation.
+
+See the [changelog](https://github.com/microsoft/flint-chart/blob/main/CHANGELOG.md#030---2026-07-19)
+for migration notes and the complete technical summary.
+
+**Full Changelog**: https://github.com/microsoft/flint-chart/compare/0.2.1...0.3.0
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 25b1e289..cc9f58cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,12 +3,56 @@
Starting with version 0.2.1, all notable changes to `flint-chart` and
`flint-chart-mcp` are documented in this file. The two npm packages are
versioned and released together, so each release entry covers both packages.
+Versions 0.2.1 and 0.2.2 were development milestones and were not published to
+npm; 0.3.0 is the next public release after 0.2.0.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+## [0.3.0] - 2026-07-19
+
+### Added
+
+- Backend-neutral chart-type recommendations and chart transformations, including
+ compatible chart-type transitions and data-preserving arrangement controls.
+- Public transform and recommendation APIs for Vega-Lite, ECharts, and Chart.js.
+- Dynamic MCP App controls that switch chart types, rearrange encodings, and edit
+ chart properties in place without rewriting the authored Flint spec, plus PNG
+ copy/download and reset actions.
+- Gantt task-height, corner-radius, and interval-label controls.
+- A Chinese-language website and translated documentation.
+
+### Changed
+
+- Improved recommendation quality and backend parity for area, bar, boxplot,
+ Gantt, scatter, waterfall, and related chart families.
+- Sparkline rows now always use independent Y scales, matching the chart's
+ per-series comparison semantics. The obsolete `independentYAxis` option is no
+ longer exposed for Sparklines.
+- Vega-Lite axes and derived text marks now share semantic formatting logic, so
+ currency and other formatted aggregate values retain their intended units.
+- Sparse stacked areas and streamgraphs now interpolate interior gaps and extend
+ the nearest measured edge value instead of dropping abruptly to zero;
+ unstacked areas connect measured points without fabricating rows.
+
+### Migration
+
+- Sparkline no longer accepts a shared Y scale. Remove
+ `chartProperties.independentYAxis` from Sparkline specs; every row now
+ self-scales. Other faceted chart types continue to support the property.
+- The grouped-chart `dodge` property no longer accepts `none`. Use `auto` to let
+ redundant groups collapse to one glyph per band, or choose `local`/`global`.
+- Vega-Lite Rose Chart no longer accepts `innerRadius`; use Pie Chart with an
+ inner radius for a donut-style display.
+
+### Fixed
+
+- Corrected Sparkline row alignment and average-value formatting.
+- Improved local dodge behavior for sparse grouped bars and boxplots across
+ supported backends.
+
## [0.2.2] - 2026-07-15
### Added
@@ -61,6 +105,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Treated only lowercase `start` and `end` Waterfall Type values as total
anchors in Vega-Lite; other values now remain floating deltas colored by sign.
-[Unreleased]: https://github.com/microsoft/flint-chart/compare/0.2.2...HEAD
-[0.2.2]: https://github.com/microsoft/flint-chart/compare/0.2.1...0.2.2
+[Unreleased]: https://github.com/microsoft/flint-chart/compare/0.3.0...HEAD
+[0.3.0]: https://github.com/microsoft/flint-chart/compare/88fbeb5ebf07f18a1cf661ebef71cc570b7425d6...0.3.0
+[0.2.2]: https://github.com/microsoft/flint-chart/compare/0.2.1...6a9d4e4155e3d9e2bed3fa9adf5316914f791478
[0.2.1]: https://github.com/microsoft/flint-chart/compare/c8e20b052ad9ddad29ba3ecfc825948c424e5ba5...0.2.1
\ No newline at end of file
diff --git a/README.md b/README.md
index 9d960b4c..00d10810 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
[](https://github.com/microsoft/flint-chart/actions/workflows/ci.yml)
[](LICENSE)
-**Please visit:** [**Flint Project Site**](https://microsoft.github.io/flint-chart/) | [**MCP Server Guide**](https://microsoft.github.io/flint-chart/#/mcp)
+**Please visit:** [**Flint Project Site**](https://microsoft.github.io/flint-chart/) | [**MCP Server Guide**](https://microsoft.github.io/flint-chart/#/mcp) | [**中文主页**](https://microsoft.github.io/flint-chart/#/zh)
Flint is a visualization intermediate language that lets **AI agents create
expressive, polished visualizations from simple, human-editable chart specs**.
@@ -44,6 +44,17 @@ This repo contains two main components:
chart guidance so they can choose a template, validate it, and open an
interactive chart view in MCP-capable clients.
+## Updates
+
+- **July 19, 2026** — Flint 0.3.0 adds dynamic chart widgets that switch chart
+ types and edit chart properties in place. ([v0.3.0](https://github.com/microsoft/flint-chart/releases/tag/0.3.0))
+- **July 15, 2026** — Flint 0.2.2 added compact dodge modes and grouped violin
+ layouts.
+- **July 13, 2026** — Flint 0.2.1 improved chart-property validation and backend
+ consistency. ([v0.2.1](https://github.com/microsoft/flint-chart/releases/tag/0.2.1))
+
+See the [changelog](CHANGELOG.md) for complete release notes.
+
diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md
index e9265dd5..c05dee2d 100644
--- a/agent-skills/flint-chart-author/SKILL.md
+++ b/agent-skills/flint-chart-author/SKILL.md
@@ -182,7 +182,7 @@ properties"). Required channels are noted.
| `"Ranged Dot Plot"` | x, y, color | dumbbell of two x per category |
| `"Strip Plot"` | x, y, color, size, column, row | jittered points; props `stepWidth`, `pointSize`, `opacity` |
| `"Bar Chart"` | x, y, color, opacity, column, row | one discrete + one measure; prop `cornerRadius` |
-| `"Grouped Bar Chart"` | x, y, group, column, row | `group` = the clustering category |
+| `"Grouped Bar Chart"` | x, y, group, column, row | `group` = the clustering category; prop `dodge` |
| `"Stacked Bar Chart"` | x, y, color, column, row | prop `stackMode` |
| `"Pyramid Chart"` | x, y, color | diverging horizontal bars |
| `"Lollipop Chart"` | x, y, color, column, row | prop `dotSize` |
@@ -190,7 +190,7 @@ properties"). Required channels are noted.
| `"Gantt Chart"` | y, x, x2, color, detail, column, row | x = start, x2 = end |
| `"Bullet Chart"` | y, x, goal, color, column, row | `goal` required (target) |
| `"Histogram"` | x, color, column, row | x = measure to bin; prop `binCount` |
-| `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers` |
+| `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers`, `dodge` |
| `"ECDF Plot"` | x, color, detail, column, row | x = measure; cumulative distribution (step line); prop `showPoints` |
| `"Heatmap"` | x, y, color, column, row | color = the measure |
| `"Line Chart"` | x, y, color, strokeDash, detail, opacity, column, row | props `interpolate`, `showPoints` |
@@ -199,11 +199,11 @@ properties"). Required channels are noted.
| `"Slope Chart"` | x, y, color, detail, column, row | two-period value change; straight segments + end points, one line per category |
| `"Area Chart"` | x, y, color, opacity, column, row | props `interpolate`, `opacity`, `stackMode` |
| `"Range Area Chart"` | x, y, y2, color, column, row | x + y + y2 required; translucent band from `y` (low) to `y2` (high), value axis fits the band (not zero) |
-| `"Violin Plot"` | x, y, color, row | x (category) + y (measure) required; mirrored KDE density per category, prop `bandwidth`; **Vega-Lite only**; `column` is used internally for the per-category panels |
+| `"Violin Plot"` | x, y, color, row | x (category) + y (measure) required; mirrored KDE density per category, prop `bandwidth`; **Vega-Lite only**; a genuine `color` subgroup splits two groups or grids 3+ groups |
| `"Streamgraph"` | x, y, color, column, row | centre-stacked areas |
| `"Density Plot"` | x, color, column, row | prop `bandwidth` |
-| `"Pie Chart"` | size, color, column, row | `size` = slice value (→ angle), `color` = category |
-| `"Rose Chart"` | x, y, color, column, row | polar bars; props `alignment`, `innerRadius`, `padAngle` |
+| `"Pie Chart"` | size, color, column, row | `size` = slice value (→ angle), `color` = category; props `innerRadius`, `sortSlices` |
+| `"Rose Chart"` | x, y, color, column, row | polar bars; props `alignment`, `padAngle`, `sortSlices` |
| `"Radar Chart"` | x, y, color, column, row | props `filled`, `fillOpacity`, `strokeWidth` |
| `"Candlestick Chart"` | x, open, high, low, close, column, row | OHLC all required |
| `"Bar Table"` | y, x, color, column, row | compact bars + value labels |
@@ -218,10 +218,9 @@ category on `x` (or `y`) plus one measure. They differ in how a **second**
category is shown — and each reads that second category from a **different
channel**:
-- `"Bar Chart"` — no second category. One bar per `x` value. A `color`
- encoding just tints the bars (or stacks segments if multiple rows share an
- `x`). It has **no `group` channel**, so a `group` encoding is silently
- ignored.
+- `"Bar Chart"` — use for a single series. When multiple rows share an `x`, a
+ second category on `color` produces stacked segments. For side-by-side bars,
+ use `"Grouped Bar Chart"` with the second category on `group`.
- `"Stacked Bar Chart"` — second category on `color`, drawn as **stacked**
segments within each bar (totals matter). Tune with `stackMode`
(`stacked` / `normalize` / `layered`).
@@ -344,7 +343,8 @@ derived). Values are clamped to the ranges shown.
| Chart type | Property | Type / range (default) | Effect |
|---|---|---|---|
| Bar Chart | `cornerRadius` | 0–15 (0) | Round bar corners (px) |
-| Bar / Area / Stacked Bar | `stackMode` | `stacked` \| `normalize` \| `layered` (unset) | Stacking behavior; `normalize` = 100% |
+| Area / Stacked Bar | `stackMode` | `stacked` \| `normalize` \| `center` \| `layered` (unset) | Stacking behavior; `normalize` = 100%, `center` = streamgraph |
+| Grouped Bar / Boxplot | `dodge` | `auto` \| `local` \| `global` (`auto`) | `local` compacts sparse groups per category; `global` preserves aligned group lanes; leave `auto` unless the user requests one |
| Line / Area / Sparkline | `interpolate` | `linear` \| `monotone` \| `step` \| `step-before` \| `step-after` \| `basis` \| `cardinal` \| `catmull-rom` (`linear`) | Curve shape |
| Line / ECDF Plot | `showPoints` | boolean (false) | Draw point markers on the line |
| Sparkline | `baseline` | `mean` \| `zero` \| `median` \| `none` (`mean`) | Reference line per spark row |
@@ -359,12 +359,13 @@ derived). Values are clamped to the ranges shown.
| Histogram | `binCount` | 5–50 (10) | Number of bins |
| Density Plot | `bandwidth` | 0.05–2 (0=auto) | Kernel bandwidth |
| Pie Chart | `innerRadius` | 0–100 (0) | Donut hole size (>0 → donut) |
+| Pie / Rose | `sortSlices` | `none` \| `descending` \| `ascending` (`none`) | Order wedges and their legend by slice value |
| Rose Chart | `alignment` | `left` \| `center` (`left`) | Wedge alignment |
-| Rose Chart | `innerRadius` | 0–100 (0) | Inner radius |
| Rose Chart | `padAngle` | 0–0.1 (0) | Gap between slices |
| Lollipop | `dotSize` | 20–300 (80) | Circle size (px) |
| Waterfall | `cornerRadius` | 0–8 (0) | Round bar corners |
| Waterfall | `totals` | `auto` \| `none` \| `first` \| `last` \| `both` (`auto`) | Which bars anchor to zero as totals (only when no Type column) |
+| Waterfall | `showTextLabels` | boolean (false) | Render value labels on bars |
| Regression | `regressionMethod` | `linear` \| `log` \| `exp` \| `pow` \| `quad` \| `poly` (`linear`) | Fit method |
| Regression | `polyOrder` | 1–5 (3) | Polynomial order (when `poly`) |
| Radar | `filled` | boolean (true) | Fill the polygon |
diff --git a/docs/reference-chartjs.md b/docs/reference-chartjs.md
index 81c5ecbd..f843ef78 100644
--- a/docs/reference-chartjs.md
+++ b/docs/reference-chartjs.md
@@ -111,7 +111,11 @@ _No template-specific parameters._
**Encoding channels:** `y`, `x`, `x2`, `color`, `column`, `row`
-_No template-specific parameters._
+| Parameter | Control | Domain | Default | Availability | Description |
+|---|---|---|---|---|---|
+| `taskHeight` | number | 40 – 90 (step 5) | `70` | always | Task bar height as a percentage of each row. |
+| `cornerRadius` | number | 0 – 8 (step 1) | `2` | always | Corner radius for supported marks. |
+| `intervalLabels` | toggle | on / off | `false` | always | Text shown on task intervals. |
## Line & Area
@@ -136,7 +140,7 @@ _No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)) | — | always | Line or area interpolation method. |
-| `opacity` | number | 0.1 – 1 (step 0.05) | `0.4` | always | Mark opacity. |
+| `opacity` | number | 0.1 – 1 (step 0.05) | `0.4` | conditional | Mark opacity. |
| `stackMode` | choice | Stacked (default) _(default)_, `layered` (Layered (overlap)) | — | always | Stacking strategy for overlapping series. |
###  Range Area Chart
diff --git a/docs/reference-echarts.md b/docs/reference-echarts.md
index bd68f276..dc657223 100644
--- a/docs/reference-echarts.md
+++ b/docs/reference-echarts.md
@@ -98,7 +98,7 @@ _No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
-| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `layered` (Layered (overlap)) | — | conditional | Stacking strategy for overlapping series. |
+| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)) | — | conditional | Stacking strategy for overlapping series. |
###  Lollipop Chart
@@ -156,7 +156,7 @@ _No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | Line or area interpolation method. |
-| `opacity` | number | 0.1 – 1 (step 0.05) | `0.7` | always | Mark opacity. |
+| `opacity` | number | 0.1 – 1 (step 0.05) | `0.7` | conditional | Mark opacity. |
| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center), `layered` (Layered (overlap)) | — | always | Stacking strategy for overlapping series. |
###  Streamgraph
@@ -274,7 +274,11 @@ _No template-specific parameters._
**Encoding channels:** `y`, `x`, `x2`, `color`, `detail`, `column`, `row`
-_No template-specific parameters._
+| Parameter | Control | Domain | Default | Availability | Description |
+|---|---|---|---|---|---|
+| `taskHeight` | number | 40 – 90 (step 5) | `70` | always | Task bar height as a percentage of each row. |
+| `cornerRadius` | number | 0 – 8 (step 1) | `2` | always | Corner radius for supported marks. |
+| `intervalLabels` | toggle | on / off | `false` | always | Text shown on task intervals. |
###  Bullet Chart
diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md
index f48a021d..574c4acc 100644
--- a/docs/reference-vegalite.md
+++ b/docs/reference-vegalite.md
@@ -98,7 +98,7 @@ The **Availability** column shows whether a parameter is `always` available or `
###  Bar Chart
-**Encoding channels:** `x`, `y`, `color`, `group`, `opacity`, `column`, `row`
+**Encoding channels:** `x`, `y`, `color`, `opacity`, `column`, `row`
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
@@ -122,7 +122,7 @@ The **Availability** column shows whether a parameter is `always` available or `
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
-| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center), `layered` (Layered (overlap)) | — | conditional | Stacking strategy for overlapping series. |
+| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center) | — | conditional | Stacking strategy for overlapping series. |
| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
###  Lollipop Chart
@@ -153,6 +153,9 @@ The **Availability** column shows whether a parameter is `always` available or `
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
+| `taskHeight` | number | 40 – 90 (step 5) | `70` | always | Task bar height as a percentage of each row. |
+| `cornerRadius` | number | 0 – 8 (step 1) | `2` | always | Corner radius for supported marks. |
+| `intervalLabels` | toggle | on / off | `false` | always | Text shown on task intervals. |
| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
| `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. |
| `logScale_y` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the y-axis. |
@@ -269,7 +272,6 @@ _No template-specific parameters._
| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. |
| `baseline` | choice | `mean` (Average), `zero` (Zero), `median` (Median), `none` (None) | `mean` | always | Reference line |
| `trendWidth` | number | 80 – 600 (step 10) | `240` | always | Sparkline width |
-| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
| `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. |
| `logScale_y` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the y-axis. |
| `includeZero_x` | toggle | on / off | `false` | conditional | Anchor the x-axis at zero. |
@@ -306,7 +308,7 @@ _No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. |
-| `opacity` | number | 0.1 – 1 (step 0.1) | `0.7` | always | Mark opacity. |
+| `opacity` | number | 0.1 – 1 (step 0.1) | `0.7` | conditional | Mark opacity. |
| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center), `layered` (Layered (overlap)) | — | conditional | Stacking strategy for overlapping series. |
| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. |
diff --git a/docs/zh-CN/DEVELOPMENT.md b/docs/zh-CN/DEVELOPMENT.md
new file mode 100644
index 00000000..ccfe6881
--- /dev/null
+++ b/docs/zh-CN/DEVELOPMENT.md
@@ -0,0 +1,72 @@
+# 开发指南
+
+使用本页在本地搭建 **flint-chart**、运行常用检查,并在需要添加新能力时找到合适的扩展路径。
+
+## 前置条件
+
+- Node 18+(见 [`packages/flint-js/.nvmrc`](../packages/flint-js/.nvmrc);若使用 nvm,请运行 `nvm use`)
+- npm 9+(workspaces)
+
+## 首次设置
+
+```bash
+git clone https://github.com/microsoft/flint-chart
+cd flint-chart
+npm install # root workspace: packages/flint-js, packages/flint-mcp, site
+```
+
+## 日常命令
+
+在**仓库根目录**运行以下命令:
+
+| 命令 | 作用 |
+|---------|----------------|
+| `npm run typecheck` | 构建/类型检查 `packages/flint-js`,并对 `packages/flint-mcp` 做类型检查 |
+| `npm run test` | 在 `packages/flint-js` 和 `packages/flint-mcp` 中运行 Vitest |
+| `npm run build` | 构建 `packages/flint-js` 和 `packages/flint-mcp` |
+| `npm run site` | 演示站点,地址 http://localhost:5274/ |
+| `npm run site:build` | 生产构建 → `site/dist/` |
+| `npm run build:mcp` | 构建 MCP 服务器 workspace |
+
+演示站点通过 Vite 将 `flint-chart` 别名指向 `packages/flint-js/src`,因此库代码的修改会在画廊和编辑器中热重载,无需重新构建 `dist/`。
+
+## 仓库结构
+
+```
+flint-chart/
+├── packages/
+│ ├── flint-js/ npm package `flint-chart`
+│ │ ├── src/core/ semantics, layout, types
+│ │ ├── src/vegalite/ Vega-Lite backend
+│ │ ├── src/echarts/ ECharts backend
+│ │ ├── src/chartjs/ Chart.js backend
+│ │ └── src/test-data/ gallery fixtures
+│ ├── flint-py/ Python port preview (PyPI package planned later)
+│ └── flint-mcp/ npm package `flint-chart-mcp`
+├── site/ landing, gallery, editor, docs browser
+├── docs/ architecture + site documentation sources
+├── agent-skills/ AI agent skill (SKILL.md)
+└── shared/test-data/ JSON fixtures (JS + Python)
+```
+
+## 图表组装流程
+
+1. **Phase 0 — 语义解析**(`packages/flint-js/src/core/resolve-semantics.ts`)
+2. **Phase 1 — 布局**(`packages/flint-js/src/core/compute-layout.ts`)
+3. **Phase 2 — 实例化**(各后端的 `assemble.ts` + templates)
+
+完整流程见 [Architecture](/documentation/architecture)。
+
+## 扩展指南
+
+根据你要扩展的层面选择对应指南:
+
+- [Extending chart templates](/documentation/adding-a-chart-template) — 在现有后端中添加新图表类型。
+- [Extending semantic types](/documentation/adding-a-semantic-type) — 让 Flint 识别新的字段含义,从而改变格式化、聚合、比例尺或颜色行为。
+- [Extending backends](/documentation/adding-a-backend) — 添加消费共享编译器输出的新渲染目标。
+
+## 测试覆盖
+
+- **冒烟测试:** `packages/flint-js/tests/smoke.test.ts`
+- **视觉覆盖:** [Gallery](/gallery),由 test-data 中的 `TEST_GENERATORS` 驱动
+- **共享 fixtures:** `shared/test-data/`,供 JS 与 Python 测试共用
diff --git a/docs/zh-CN/adding-a-backend.md b/docs/zh-CN/adding-a-backend.md
new file mode 100644
index 00000000..9a16df05
--- /dev/null
+++ b/docs/zh-CN/adding-a-backend.md
@@ -0,0 +1,138 @@
+# 扩展后端
+
+当 Flint 需要面向新的渲染库或 spec 格式时,添加后端。后端是 `assemble(input)` 编排器加上 `templates/` 注册表;二者共同将共享编译器输出转换为原生图表 spec。现有参考实现位于 `packages/flint-js/src/` 下的 `vegalite/`、`echarts/` 和 `chartjs/`。
+
+流水线阶段与仓库结构见 [Architecture](/documentation/architecture)。
+
+---
+
+## 目录
+
+- [§1 创建骨架](#1-创建骨架)
+- [§2 遵循组装契约](#2-遵循组装契约)
+- [§3 添加模板](#3-添加模板)
+- [§4 接入包](#4-接入包)
+- [§5 站点与画廊](#5-站点与画廊)
+- [§6 验收清单](#6-验收清单)
+- [§7 相关文档](#7-相关文档)
+
+---
+
+# §1 创建骨架
+
+```
+packages/flint-js/src//
+├── index.ts # public barrel
+├── assemble.ts # 编排入口:ChartAssemblyInput → 后端配置
+├── instantiate-spec.ts # encoding + layout → spec (optional; some backends inline this)
+├── recommendation.ts # chart-type recommendations (optional)
+└── templates/
+ ├── index.ts # category map + getTemplateDef()
+ ├── bar.ts
+ ├── line.ts
+ └── …
+```
+
+从零开始前,先复制最接近的现有后端。Vega-Lite 是共享流水线最完整的参考;ECharts 额外包含 `colormap.ts` 和 `facet.ts` 以处理后端特定关注点。
+
+---
+
+# §2 遵循组装契约
+
+```typescript
+function assemble(input: ChartAssemblyInput):
+```
+
+`ChartAssemblyInput` 定义于 `packages/flint-js/src/core/types.ts`,包含 `data`、`chart_spec`、`semantic_types`、`options` 及相关字段。
+
+### 流水线(不要跳过 core 阶段)
+
+编排器**协调** `core/`,不应从原始字段类型重新推导格式、零基线或颜色。
+
+```text
+PRE-PHASE normalizeStaticSeries(), applyEncodingOverrides()
+ (may need a preliminary resolveChannelSemantics for types)
+
+PHASE 0 resolveChannelSemantics() → Record
+ computeZeroDecision() per quantitative x/y (needs template mark)
+ chartProperties overrides (includeZero_*, logScale_*, …)
+
+STEP 0a template.declareLayoutMode?.() → LayoutDeclaration
+
+STEP 0b convertTemporalData()
+
+STEP 0c computeChannelBudgets() + filterOverflow()
+
+PHASE 1 computeLayout() → LayoutResult
+
+PHASE 2 build backend encodings
+ template.instantiate(spec, InstantiateContext)
+ apply layout (vlApplyLayoutToSpec / ecApplyLayoutToSpec / …)
+ postProcess?, tooltips, facet combine
+```
+
+规范顺序见 `packages/flint-js/src/vegalite/assemble.ts`(文件头 + `assembleVegaLite`)。
+
+**IR 边界:** 下游代码读取扁平的 `ChannelSemantics` 和 `LayoutResult`,而不是重新检查语义类型字符串。
+
+---
+
+# §3 添加模板
+
+模板编码**形状**,而非**决策**。若模板需要按 `field.type === 'temporal'` 分支,应将该逻辑移到 `core/`。
+
+每个模板导出一个 `ChartTemplateDef`(`core/types.ts`):
+
+| 字段 | 作用 |
+|---|---|
+| `chart` | 显示名称 — 必须与 `chart_spec.chartType` 一致 |
+| `template` | 原生 spec 骨架(mark + encoding 结构) |
+| `channels` | 允许的编码槽位 |
+| `markCognitiveChannel` | `position` / `length` / `area` / `color` — 驱动零基线与压缩 |
+| `declareLayoutMode?` | 布局前的轴标志(banded vs continuous、σ 覆盖) |
+| `instantiate` | 根据 `InstantiateContext` 修改 spec(encodings、layout、semantics) |
+| `properties?` | 可配置的图表属性 |
+| `postProcess?` | 布局后的最终视觉微调 |
+
+在 `templates/index.ts` 中注册:导入 defs,加入 category map,并暴露 `*GetTemplateDef(chartType)` 为 `find(t => t.chart === chartType)`。
+
+---
+
+# §4 接入包
+
+1. **Barrel** — 在 `packages/flint-js/src/index.ts` 中 `export * from './'`
+2. **Bundle** — 在 `packages/flint-js/tsup.config.ts` 中添加 `/index` 入口
+3. **Exports** — 在 `packages/flint-js/package.json#exports` 中添加 `"./"` 子路径
+4. **冒烟测试** — 在 `packages/flint-js/tests/smoke.test.ts` 中扩展一条 `assemble()` 形状断言
+5. **Gallery 数据** — 在 `src/test-data/` 中添加 `gen*Tests()`,并在 `TEST_GENERATORS` 中注册
+
+---
+
+# §5 站点与画廊
+
+- **Gallery 开发服务器:** 在仓库根目录运行 `npm run site`,然后打开 `/gallery`
+- **Supported backends:** 若新后端应出现在 UI 中,更新 `site/src/shared/supported-backends.ts`
+- **Renderers:** 仅当 spec 格式无法复用 `VegaLiteView`、`EChartsView` 或 `ChartjsView` 时,才添加新的 React 视图(`site/src/components/`)。`TripleChart` 当前覆盖 VL + ECharts + Chart.js。
+
+可选:若 MCP 客户端应能调用该组装器,将其接入 `agent-skills/mcp-server/`。
+
+---
+
+# §6 验收清单
+
+后端就绪的标志:
+
+- [ ] Bar、line、area 和 scatter 模板在标准 gallery 矩阵上渲染正确
+- [ ] `tests/smoke.test.ts` 对新组装器通过
+- [ ] 仓库根目录的 `npm run typecheck` 和 `npm run test` 通过
+- [ ] 至少有一个专用 test-data 生成器覆盖后端特定选项
+
+**对等说明:** 并非每个 `chart` 名称目前在各后端都存在。记录你移植了哪些模板;跨后端对等是目标,而非首次合并的前置条件。
+
+---
+
+# §7 相关文档
+
+- [Extending chart templates](/documentation/adding-a-chart-template) — `ChartTemplateDef` 编写
+- [Auto Layout Algorithm](/documentation/layout-model) — `computeLayout()` 的期望输入
+- [API reference](/documentation/api-reference) — `ChartAssemblyInput` 与组装器入口
diff --git a/docs/zh-CN/adding-a-chart-template.md b/docs/zh-CN/adding-a-chart-template.md
new file mode 100644
index 00000000..a2d35c10
--- /dev/null
+++ b/docs/zh-CN/adding-a-chart-template.md
@@ -0,0 +1,133 @@
+# 扩展图表模板
+
+当后端已存在、你想为其添加另一种图表类型时使用本指南。若要添加新的渲染目标,请从 [Extending backends](/documentation/adding-a-backend) 开始。
+
+---
+
+## 目录
+
+- [§1 选择图表名称与通道](#1-选择图表名称与通道)
+- [§2 编写模板](#2-编写模板)
+- [§3 注册模板](#3-注册模板)
+- [§4 添加测试数据与 gallery 覆盖](#4-添加测试数据与-gallery-覆盖)
+- [§5 跨后端对等](#5-跨后端对等)
+- [§6 相关文档](#6-相关文档)
+
+---
+
+# §1 选择图表名称与通道
+
+公开标识是 `ChartTemplateDef` 上的 **`chart` 字符串**。它必须与 `chart_spec.chartType` 完全一致,例如 `"Scatter Plot"`。
+
+只选择标记实际使用的通道。以相似模板为起点复制:
+
+| 族 | Vega-Lite 参考 |
+|---|---|
+| Scatter / point | `vegalite/templates/scatter.ts` |
+| Bar / column | `vegalite/templates/bar.ts` |
+| Line / area | `vegalite/templates/line.ts` |
+| Radial | `vegalite/templates/pie.ts` |
+
+ECharts 与 Chart.js 在各自的 `templates/` 目录下使用相同的 `ChartTemplateDef` 接口。
+
+---
+
+# §2 编写模板
+
+`ChartTemplateDef` 位于 `packages/flint-js/src/core/types.ts`。
+
+```typescript
+import { ChartTemplateDef } from '../../core/types';
+import { defaultBuildEncodings } from './utils';
+
+export const dotPlotDef: ChartTemplateDef = {
+ chart: 'Dot Plot',
+ template: { mark: 'circle', encoding: {} },
+ channels: ['x', 'y', 'color', 'size', 'column', 'row'],
+ markCognitiveChannel: 'position',
+
+ declareLayoutMode: (channelSemantics, table, chartProperties) => {
+ // optional: banded axes, σ overrides, Q→O conversion
+ return { /* LayoutDeclaration */ };
+ },
+
+ instantiate: (spec, ctx) => {
+ defaultBuildEncodings(spec, ctx.resolvedEncodings);
+ // ctx.channelSemantics, ctx.layout, ctx.table, ctx.chartProperties, …
+ },
+
+ properties: [
+ { key: 'opacity', label: 'Opacity', type: 'continuous',
+ min: 0.1, max: 1, step: 0.05, defaultValue: 1 },
+ ],
+};
+```
+
+### 关键规则
+
+1. **`template`** — 最小原生骨架;`instantiate` 填充 encodings 与 mark 属性。
+2. **`markCognitiveChannel`** — 告诉编译器读者如何解码数值(影响零基线与 [Auto Layout Algorithm](/documentation/layout-model) 压缩)。
+3. **`instantiate`** — 接收 `template` 的**深拷贝**以及 `InstantiateContext`(已解析 encodings、`ChannelSemantics`、`LayoutResult`、数据表、画布尺寸)。
+4. **不做语义分支** — 读取 `ctx.channelSemantics[channel].format`、`.type`、`.zero` 等;不要按原始字段名或存储类型 switch。
+
+可选钩子:`postProcess`(布局后)、`encodingActions`(shelf 快捷操作)。
+
+---
+
+# §3 注册模板
+
+在 `packages/flint-js/src//templates/index.ts` 中:
+
+1. 导入新的 `*Def` 常量。
+2. 将其加入 `*TemplateDefs` 内合适的 category 数组(例如 `scatterTemplates`)。
+3. 确保 `*GetTemplateDef(chartType)` 能找到它:`defs.find(t => t.chart === chartType)`。
+
+Vega-Lite 还会运行 `withInjectedProperties()`,为各模板附加共享的分面与对数比例尺属性。若你的图表需要相同钩子,请参照该文件中的现有条目。
+
+---
+
+# §4 添加测试数据与 gallery 覆盖
+
+### 生成器模式
+
+`TestCase` 接口位于 `packages/flint-js/src/test-data/types.ts`。
+
+典型流程(见 `scatter-tests.ts`、`bar-tests.ts`):
+
+1. 定义小型参数矩阵(基数、是否着色、是否分面)。
+2. 导出 `genTests(): TestCase[]`,其中 `chartType` 与 `ChartTemplateDef.chart` 一致。
+3. 在 `packages/flint-js/src/test-data/index.ts` 中注册:
+
+```typescript
+TEST_GENERATORS['Dot Plot'] = genDotPlotTests;
+```
+
+4. 可选地在 `gallery-tree.ts` 中添加列出该生成器键的页面。
+
+### 验证
+
+```bash
+npm run typecheck
+npm run test
+npm run site # Gallery → find your chart type
+```
+
+在 3–6 个代表性用例上检查格式化、布局拉伸、图例与分面行为。
+
+---
+
+# §5 跨后端对等
+
+面向用户的契约是:在模板存在的前提下,相同的 `chartType` 字符串应能在 `assembleVegaLite`、`assembleECharts` 和 `assembleChartjs` 上工作。实践中:
+
+- 先移植到你立即需要的后端;其余可另开跟进。
+- `site/src/shared/supported-backends.ts` 按各后端注册表过滤图表类型。仅 Vega-Lite 的模板在注册到 ECharts 之前不会出现在 ECharts 中。
+
+---
+
+# §6 相关文档
+
+- [Extending backends](/documentation/adding-a-backend) — 完整组装器接线
+- [Semantic Type](/documentation/semantic-types) — `channelSemantics` 包含的内容
+- [Auto Layout Algorithm](/documentation/layout-model) — `declareLayoutMode` 与 stretch 模型
+- [API reference](/documentation/api-reference) — `chart_spec.chartType` 与 encodings
diff --git a/docs/zh-CN/adding-a-semantic-type.md b/docs/zh-CN/adding-a-semantic-type.md
new file mode 100644
index 00000000..1ccefc91
--- /dev/null
+++ b/docs/zh-CN/adding-a-semantic-type.md
@@ -0,0 +1,128 @@
+# 扩展语义类型
+
+语义类型是 LLM 和用户为字段附加的标签。当新的字段含义需要改变 Flint 如何格式化数值、聚合数据、选择比例尺或分配颜色时,应扩展语义类型。若 Flint 无法识别某类型,会优雅地回退到 `Unknown`。
+
+完整类型层级与解析规则见 [Semantic Type](/documentation/semantic-types)。
+
+---
+
+## 目录
+
+- [§1 判断是否需要新类型](#1-判断是否需要新类型)
+- [§2 注册类型](#2-注册类型)
+- [§3 同步常量与注解](#3-同步常量与注解)
+- [§4 测试与验证](#4-测试与验证)
+- [§5 相关文档](#5-相关文档)
+
+---
+
+# §1 判断是否需要新类型
+
+在添加类型之前,确认它与 T1 父类型相比**确实会改变编译行为**。避免注册同义词,例如 `Money`、`Price` 和 `Currency`;在注册表中选定一个名称,必要时在 agent 提示词中为其他名称做别名。
+
+| 问题 | 若答案为是 |
+|---|---|
+| 是否已有 T2 类型以相同方式编译? | 使用该类型,并通过 `SemanticAnnotation` 元数据补充 |
+| 该类型是否需要有界比例尺或单位? | 保留该类型;在注解中记录所需的 `intrinsicDomain` / `unit` |
+| 是否仅为 agent 提供更友好的标签? | 优先使用 T1(`Amount`、`SignedMeasure`),而非新建 T2 |
+
+已弃用类型的说明与完整清单见 [Semantic Type §2.4](/documentation/semantic-types#24-tier-2-specific-types)。
+
+---
+
+# §2 注册类型
+
+**单一事实来源:** `packages/flint-js/src/core/type-registry.ts`
+
+在 `TYPE_REGISTRY` 中添加一条记录。记录的键是 **T2 类型名称**,也是用户在 `semantic_types` 中传入的字符串。
+
+```typescript
+PercentageChange: {
+ t0: 'Measure',
+ t1: 'SignedMeasure',
+ visEncodings: ['quantitative'],
+ aggRole: 'signed-additive',
+ domainShape: 'open',
+ diverging: 'conditional',
+ formatClass: 'percent',
+ zeroBaseline: 'contextual',
+ zeroPad: 0.05,
+},
+```
+
+### `TypeRegistryEntry` 字段
+
+| 字段 | 取值 | 驱动 |
+|---|---|---|
+| `t0` | `T0Family` | 解析器 / 编码族 |
+| `t1` | `T1Category` | 中层规则选择 |
+| `visEncodings` | `VisCategory[]`(优先级顺序) | 默认 Q/O/N/T 编码 |
+| `aggRole` | `additive`, `intensive`, `signed-additive`, `dimension`, `identifier` | 通过 `resolveAggregationDefault()` 得到 `aggregationDefault` |
+| `domainShape` | `open`, `bounded`, `fixed`, `cyclic` | 域约束、刻度、极坐标提示 |
+| `diverging` | `none`, `conditional`, `inherent` | 发散色与中点 |
+| `formatClass` | `currency`, `percent`, `unit-suffix`, `integer`, `decimal`, `plain` | 通过 `resolveFormat()` 设置轴/工具提示格式 |
+| `zeroBaseline` | `meaningful`, `arbitrary`, `contextual`, `none` | Stage 4 中 `computeZeroDecision()` 的提示 |
+| `zeroPad` | `number`(0–1 的小数) | 轴不包含零时的内边距 |
+
+查询 API:同文件中的 `getRegistryEntry()`、`isRegistered()`、`getRegisteredTypes()`。
+
+**不在注册表中**(在其他位置解析):显式 `pattern` 字符串、轴反转、`colorScheme` 名称以及 `stackable`。这些来自 `field-semantics.ts` / `resolve-semantics.ts`,它们将注册表维度与数据和通道上下文结合。
+
+---
+
+# §3 同步常量与注解
+
+### `SemanticTypes` 常量
+
+在 `packages/flint-js/src/core/semantic-types.ts` 中添加对应键,以便调用点安全引用该类型:
+
+```typescript
+export const SemanticTypes = {
+ // ...
+ PercentageChange: 'PercentageChange',
+} as const;
+```
+
+### 字段级元数据(可选)
+
+**不属于**类型内在属性的逐字段细节应放在 `SemanticAnnotation`(`field-semantics.ts`)上,而非 `TYPE_REGISTRY`:
+
+```typescript
+interface SemanticAnnotation {
+ semanticType: string;
+ intrinsicDomain?: [number, number]; // e.g. Rating [1, 5]
+ unit?: string; // e.g. USD, °C
+ sortOrder?: string[]; // custom ordinal order
+}
+```
+
+图表输入接受 `Record` 作为 `semantic_types`。
+
+---
+
+# §4 测试与验证
+
+1. **Gallery 用例** — 在 `packages/flint-js/src/test-data/semantic-tests.ts` 中添加或扩展生成器,在相关通道上使用新类型。
+2. **注册生成器** — 若这会创建新的 gallery 页面,在 `packages/flint-js/src/test-data/index.ts`(`TEST_GENERATORS`)中接线,并可选地在 `gallery-tree.ts` 中注册。
+3. **运行检查:**
+
+```bash
+npm run typecheck
+npm run test
+npm run site # open Gallery → Semantic Context (or your new page)
+```
+
+验证:
+
+- 轴格式符合 `formatClass`(以及注解中的 `unit`)
+- 在 `autoAggregate` 适用时,聚合遵循 `aggRole`
+- 零基线与反转符合类型 + 标记(条形 vs 折线)
+- 仅在 `diverging` 与数据需要时出现发散色
+
+---
+
+# §5 相关文档
+
+- [Semantic Type](/documentation/semantic-types) — T0/T1/T2 层级、注解、解析规则
+- [Architecture](/documentation/architecture) — `resolveChannelSemantics` 在流水线中的位置
+- [API reference](/documentation/api-reference) — `ChartAssemblyInput` 上的 `semantic_types`
diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md
new file mode 100644
index 00000000..3099edf7
--- /dev/null
+++ b/docs/zh-CN/api-reference.md
@@ -0,0 +1,265 @@
+# API 参考
+
+JavaScript / TypeScript 包:**`flint-chart`**(`packages/flint-js`)。
+
+Python 移植:**`packages/flint-py`** 为源码预览。其输入形状与 JS API 一致,但 PyPI 发布计划在后续版本。
+
+概念背景:[概览](/documentation/overview) · 流水线:[架构](/documentation/architecture)
+
+---
+
+## 目录
+
+- [§1 Flint 规范映射](#1-flint-spec-mapping)
+- [§2 Assembler](#2-assemblers)
+- [§3 ChartAssemblyInput](#3-chartassemblyinput)
+- [§4 编码与选项](#4-encodings-and-options)
+- [§5 完整示例](#5-complete-example)
+- [§6 模板发现](#6-template-discovery)
+- [§7 核心工具](#7-core-utilities)
+- [§8 溢出与警告](#8-overflow-and-warnings)
+- [§9 子路径导出](#9-subpath-exports)
+- [§10 相关](#10-related)
+
+---
+
+# §1 Flint 规范映射
+
+| Flint | API 字段 | 内容 |
+|---------------|-----------|----------|
+| 原始表 | `data` | `{ values: rows[] }` 或 `{ url: "..." }` |
+| **dataSpec** | `semantic_types` | `field → string` 或 `field → SemanticAnnotation` |
+| **chartSpec** | `chart_spec` | `chartType`、`encodings`、`canvasSize`、`chartProperties` |
+
+每个数据集编写一次 `semantic_types`,并在多张图表间复用。探索阶段通常只有 `chart_spec` 会变化。
+
+### SemanticAnnotation(内联于 `semantic_types`)
+
+```ts
+interface SemanticAnnotation {
+ semanticType: string;
+ intrinsicDomain?: [number, number]; // e.g. Rating [1, 5]
+ unit?: string; // e.g. USD, °C
+ sortOrder?: string[]; // custom ordinal order
+}
+```
+
+裸字符串简写:`"Price"` 等价于 `{ semanticType: "Price" }`。
+
+---
+
+# §2 Assembler
+
+所有后端接受相同的 `ChartAssemblyInput`,并返回可直接渲染的对象。
+
+```ts
+import {
+ assembleVegaLite,
+ assembleECharts,
+ assembleChartjs,
+} from 'flint-chart';
+
+const vlSpec = assembleVegaLite(input);
+const ecSpec = assembleECharts(input);
+const cjsSpec = assembleChartjs(input);
+```
+
+| 导出 | 返回 |
+|--------|---------|
+| `assembleVegaLite` | Vega-Lite JSON spec |
+| `assembleECharts` | ECharts `option` object |
+| `assembleChartjs` | Chart.js configuration |
+
+若某后端不支持某 `chartType`,assembler 会在渲染前抛出。可用 `vlGetTemplateDef`、`ecGetTemplateDef` 或 `cjsGetTemplateDef` 检查支持情况。
+
+---
+
+# §3 ChartAssemblyInput
+
+```ts
+interface ChartAssemblyInput {
+ data: { values: Record[] } | { url: string };
+ semantic_types?: Record;
+ chart_spec: {
+ chartType: string;
+ encodings: Record; // string = field shorthand
+ baseSize?: { width: number; height: number }; // target layout size, default 400×320
+ canvasSize?: { width: number; height: number }; // optional hard ceiling on stretch
+ chartProperties?: Record;
+ };
+ options?: AssembleOptions;
+ field_display_names?: Record;
+}
+```
+
+### `data`
+
+| 形式 | 说明 |
+|------|-------------|
+| `{ values: rows[] }` | 内联行对象(编辑器与教程) |
+| `{ url: "..." }` | 由运行环境解析的数据路径或 URL。Flint MCP 仅支持本地 JSON、CSV 和 TSV 文件,不读取远程 URL。 |
+
+### `semantic_types`
+
+将列名映射到语义类型。这驱动编码类型、格式化、聚合默认值、颜色类与布局。见[语义类型](/documentation/semantic-types)。
+
+### `chart_spec`
+
+| 字段 | 说明 |
+|-------|-------------|
+| `chartType` | 模板名称 — 须与后端注册表项匹配(`"Bar Chart"`、`"Heatmap"` 等) |
+| `encodings` | 通道 → 编码映射 |
+| `baseSize` | **目标**布局尺寸(像素,默认 400×320):典型数据下图表瞄准的尺寸。密集数据可能超出,直至上限。 |
+| `canvasSize` | **硬上限:** 图表可达到的最大尺寸,含分面网格。若省略,上限为 `baseSize × options.maxStretch`(默认 1.5×)。各维度上限为 `βx = canvasSize.width / baseSize.width`、`βy = canvasSize.height / baseSize.height`(均 ≥ 1)。基准会被钳制到上限,因此单独设置 `canvasSize` 即相当于固定框,图表会填充并缩小以适配而不溢出。 |
+| `chartProperties` | 模板特定开关(例如 `orient`、`opacity`) |
+
+> **base 与 canvas,一句话:** `baseSize` 是图表*瞄准*的尺寸;`canvasSize` 是*绝不超过*的尺寸。固定插槽用 `canvasSize`,舒适目标且密集数据可增长用 `baseSize`。见[示例:自动布局](/documentation/chart-sizing)。
+
+---
+
+# §4 编码与选项
+
+### ChartEncoding
+
+```ts
+interface ChartEncoding {
+ field?: string;
+ type?: 'quantitative' | 'nominal' | 'ordinal' | 'temporal';
+ aggregate?: 'count' | 'sum' | 'average' | 'mean';
+ sortOrder?: 'ascending' | 'descending';
+ sortBy?: string;
+ scheme?: string;
+}
+```
+
+显式 `type` 覆盖语义推断。设置 `aggregate` 表示由 Flint 自行折叠行——按其他(未聚合)字段通道分组,并产生名为 `${field}_${aggregate}` 的派生列(`count` → `_count`)。`average` 与 `mean` 同义。多数调用方仍应在上游聚合数据;若已聚合,省略 `aggregate` 并按列名引用派生列。
+
+常见通道:`x`、`y`、`color`、`size`、`shape`、`column`、`row`、`group`、`detail`。
+
+### AssembleOptions(节选)
+
+```ts
+interface AssembleOptions {
+ addTooltips?: boolean; // default false
+ elasticity?: number; // discrete stretch exponent (default 0.5)
+ maxStretch?: number; // default stretch cap when no canvasSize ceiling (default 1.5)
+ maxStretchX?: number; // per-dimension width cap (derived from canvasSize)
+ maxStretchY?: number; // per-dimension height cap (derived from canvasSize)
+ facetElasticity?: number; // facet stretch (default 0.3)
+ minStep?: number; // min px per discrete item (default 6)
+ minSubplotSize?: number; // min facet subplot px (default 60)
+ maxColorValues?: number; // color cardinality before truncation (default 24)
+ stepPadding?: number; // band inner padding fraction (default 0.1)
+ defaultBandSize?: number; // baseline px per category (backend-tuned)
+}
+```
+
+完整列表:`packages/flint-js/src/core/types.ts`(`AssembleOptions`)。行为见[自动布局算法](/documentation/layout-model)。
+
+---
+
+# §5 完整示例
+
+```ts
+const input: ChartAssemblyInput = {
+ data: {
+ values: [
+ { quarter: 'Q1', revenue: 1200 },
+ { quarter: 'Q2', revenue: 1450 },
+ { quarter: 'Q3', revenue: 980 },
+ { quarter: 'Q4', revenue: 1800 },
+ ],
+ },
+ semantic_types: { quarter: 'Quarter', revenue: 'Price' },
+ chart_spec: {
+ chartType: 'Bar Chart',
+ encodings: {
+ x: { field: 'quarter' },
+ y: { field: 'revenue' },
+ },
+ baseSize: { width: 480, height: 320 },
+ },
+};
+
+const spec = assembleVegaLite(input);
+```
+
+---
+
+# §6 模板发现
+
+```ts
+import {
+ vlTemplateDefs,
+ vlGetTemplateDef,
+ vlGetTemplateChannels,
+ ecGetTemplateDef,
+ cjsGetTemplateDef,
+} from 'flint-chart';
+
+Object.keys(vlTemplateDefs);
+// ["Points", "Bars", "Lines & Areas", …]
+
+vlGetTemplateChannels('Scatter Plot');
+// ["x", "y", "color", "size", "opacity", "column", "row"]
+```
+
+---
+
+# §7 核心工具
+
+从 `flint-chart` 与 `flint-chart/core` 再导出:
+
+| 符号 | 用途 |
+|--------|---------|
+| `inferVisCategory` | 从原始数据推断粗粒度可视化类别 |
+| `getVisCategory` | 按语义类型字符串查找类别 |
+| `getRegistryEntry` | 查询类型的 `TypeRegistryEntry` |
+| `channels`、`channelGroups` | 通道元数据 |
+
+关键类型:`ChartAssemblyInput`、`ChartEncoding`、`ChartTemplateDef`、`AssembleOptions`、`ChartWarning`、`ChannelSemantics`。
+
+---
+
+# §8 溢出与警告
+
+当离散通道超出布局预算时,编译器会:
+
+1. 计算可容纳多少项([自动布局算法 §2](/documentation/layout-model#2-discrete-axis-elastic-budget-model))
+2. 应用模板溢出策略
+3. 将数据过滤为保留值
+4. 将警告附加到结果
+
+默认策略优先级:
+
+1. 连接标记(折线、面积)— 保留所有点
+2. 用户指定排序 — 保留前/后 N 项
+3. 对侧定量轴 — 排序并截断
+4. 柱图 + count — 先 sum 聚合再截断
+5. 数值字段 — 数值排序,取前 N
+6. 回退 — 按数据顺序取前 N
+
+在集成代码中检查 `_warnings` 或 `ChartWarning` 数组,以便在 UI 中展示截断信息。
+
+---
+
+# §9 子路径导出
+
+| 导入路径 | 内容 |
+|-------------|----------|
+| `flint-chart` | Assembler + 主要再导出 |
+| `flint-chart/core` | 类型、语义、布局 |
+| `flint-chart/vegalite` | VL 模板与 `assembleVegaLite` |
+| `flint-chart/echarts` | ECharts 模板与 `assembleECharts` |
+| `flint-chart/chartjs` | Chart.js 模板与 `assembleChartjs` |
+| `flint-chart/test-data` | 图库生成器(`TEST_GENERATORS`) |
+
+---
+
+# §10 相关
+
+- [概览](/documentation/overview) — dataSpec + chartSpec 动机
+- [架构](/documentation/architecture) — 三阶段流水线
+- [语义类型](/documentation/semantic-types) — 类型层次与解析
+- [入门指南](/documentation/getting-started) — 动手演练
+- [扩展后端](/documentation/adding-a-backend) — 新 `assemble*()` 目标
diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md
new file mode 100644
index 00000000..1977609e
--- /dev/null
+++ b/docs/zh-CN/architecture.md
@@ -0,0 +1,195 @@
+# 架构
+
+Flint 是一种与库无关的可视化中间语言。每个 `assemble*()` 入口都使用相同的**编译器前端**和**优化器**;仅**代码生成器**因后端而异。
+
+动机与规范示例见[概览](/documentation/overview)。输入类型见 [API 参考](/documentation/api-reference)。
+
+---
+
+## 目录
+
+- [§1 设计原则](#1-design-principles)
+- [§2 三阶段流水线](#2-three-stage-pipeline)
+- [§3 阶段 1 — 编译器前端](#3-stage-1-compiler-frontend)
+- [§4 阶段 2 — 优化器](#4-stage-2-optimizer)
+- [§5 阶段 3 — 代码生成器](#5-stage-3-code-generator)
+- [§6 输入](#6-inputs)
+- [§7 溢出与警告](#7-overflow-and-warnings)
+- [§8 仓库布局](#8-repository-layout)
+- [§9 相关](#9-related)
+
+---
+
+# §1 设计原则
+
+1. **语义优先** — `semantic_types` 指导解析、聚合、零基线、发散检测和格式化。原始存储类型只是起点。
+2. **最小图表表面** — `chart_spec` 提供图表类型与通道绑定,通常约 10 行。坐标轴、比例尺、图例和步长由编译器推导。
+3. **动态模板** — 每个 `chartType` 映射到 `ChartTemplateDef`;其 `instantiate()` 钩子消费完整编译上下文,并适应基数与语义。
+4. **无 UI 依赖** — 核心为纯 TypeScript(`packages/flint-js`),可从智能体、笔记本、服务器或本站运行。Python 包计划在后续版本发布。
+
+大部分设计逻辑位于阶段 1–2,且在各后端间完全相同。
+
+---
+
+# §2 三阶段流水线
+
+
+
+| 阶段 | 作用 | 实现 | 关键输出 |
+|-------|------------|----------------|-------------|
+| **1. 编译器前端** | 解析语义上下文 | Phase 0 — `resolveChannelSemantics()` | 每通道 `ChannelSemantics` |
+| **2. 优化器** | 将布局适配画布 | Phase 1 — `computeLayout()`、`filterOverflow()` | `LayoutResult`、截断数据 |
+| **3. 代码生成器** | 输出库原生规范 | Phase 2 — `build*Encodings()`、`template.instantiate()` | VL / EC / CJS spec |
+
+```text
+assembleVegaLite(input) // or assembleECharts, assembleChartjs
+ │
+ ▼
+══ STAGE 1 — COMPILER FRONTEND (core/) ═════════════════════════
+ │
+ ├── resolveChannelSemantics() semantic_types + data → ChannelSemantics
+ ├── computeZeroDecision() per quantitative axis (needs template mark)
+ ├── declareLayoutMode() template layout intent (optional)
+ └── convertTemporalData() semantic-driven date parsing
+ │
+ ▼
+══ STAGE 2 — OPTIMIZER (core/) ══════════════════════════════════
+ │
+ ├── computeChannelBudgets() + filterOverflow()
+ └── computeLayout()
+ • Discrete axes — elastic budget (bars, heatmap cells)
+ • Continuous axes — gas-pressure stretch (scatter, line)
+ • Global — facet grid, aspect ratio, radial / area models
+ │
+ ▼
+══ STAGE 3 — CODE GENERATOR (per backend) ═══════════════════════
+ │
+ ├── build*Encodings() backend encoding objects
+ ├── template.instantiate() dynamic template hook
+ ├── restructureFacets() VL / ECharts faceting
+ └── applyLayoutToSpec() step, width/height, padding
+ │
+ ▼
+ 后端配置 + 可选警告
+```
+
+规范编排:`packages/flint-js/src/vegalite/assemble.ts`。
+
+---
+
+# §3 阶段 1 — 编译器前端
+
+解析分两层;完整流水线见[语义类型 §4](/documentation/semantic-types#4-compilation-pipeline)。
+
+### 字段属性
+
+按列、与图表无关:格式类、聚合角色、域形状、发散提示、规范顺序。由 `type-registry.ts` 与可选内联注解(`intrinsicDomain`、`unit`、`sortOrder`)驱动。
+
+### 通道属性
+
+图表上下文落地。同一 `YearMonth` 字段在折线图的 `x` 上可能是时间型,在另一视图的 `color` 上可能是分类型。通道语义防止年月整数被当作定量幅度。
+
+**IR:** `ChannelSemantics` — 扁平、与后端无关的记录,供布局与所有模板消费。
+
+分层类型(T0 → T1 → T2)在智能体提供粗粒度标签时允许优雅降级。
+
+### 命名视图变换
+
+Flint 将部分替代方案提供为**命名视图**,用户或智能体无需重写图表规范。命名视图会小范围调整字段映射,例如翻转坐标轴、交换分类轴与颜色系列、将系列移到分面,或改用相关图表类型渲染相同字段。应用只需在 `chart_spec.chartProperties.pivot` 中保存所选状态 id;编译器会重新计算字段映射,再执行语义解析、溢出处理、布局和后端生成。
+
+该模型在群论意义上成立,但刻意保持实用。从作者分配 `a0` 出发,沿四个算子生成的轨道遍历:
+
+| 符号 | 生成元 | 示例状态 id | 含义 |
+|--------|-----------|------------------|---------|
+| `τ` | transpose | `flip:x-y` | 整体翻转两个轴槽位,保持占用关系 |
+| `σ` | permute | `swap:y-color` | 将位置字段与同配置的辅助通道交换 |
+| `γ` | shift | `series:row` | 在 color/group/facet 通道间路由一个离散系列字段 |
+| `θ` | transition | `type:Strip Plot` | 用兄弟模板重新渲染相同路由字段 |
+
+可见的 View 控件是有效性检查与去重后的有限轨道。去重是稳定子群的具象形式:翻转两次回到 `Default`,先分面再抖动可能与直接抖动坍缩为同一 Strip Plot,Scatter → Strip Plot → Scatter 这类图表类型往返会折叠回作者编写的散点图。兼容性检查也有类型约束:`σ` 仅在同一字段配置内交换(度量与度量、类别与类别),而 `τ` 允许跨配置,因为它翻转的是轴槽位而非字段角色。折线图省略 `τ`,因此 Flint 从不提供垂直折线图。
+
+由于轨道在 Flint 的后端中立编码 IR 上计算,相同的 View 状态 id 适用于 Vega-Lite、ECharts 和 Chart.js。各后端接收已变换的编码映射;仅 `θ` 需要后端特定的模板查找,以便兄弟图表自身的实例化逻辑接管。
+
+---
+
+# §4 阶段 2 — 优化器
+
+优化器接收 `baseSize`(目标)和可选的 `canvasSize` 上限,然后产生在可用空间内保持图表可读的 `LayoutResult`。
+
+### 局部优化
+
+每个布局维度(x、y、group、分面列/行、radius)都是弹性容器:
+
+| 编码类 | 行为 |
+|----------------|----------|
+| 离散(柱、热力图单元格) | 向最小可读步长压缩;必要时拉伸画布 |
+| 连续(散点、折线) | 标记密度超过重叠预算时拉伸 |
+
+### 全局优化
+
+宽高比(连接标记的 banking-to-45°)、分面行列换行,以及由组件数量确定尺寸的非笛卡尔图表(treemap、gauge、pie)。
+
+实现模型:[自动布局算法](/documentation/layout-model) — §2 弹性预算、§3 气压、§4 周长、§5 面积。
+
+---
+
+# §5 阶段 3 — 代码生成器
+
+后端生成器将优化后的上下文翻译为库原生语法。每个 `chartType` 注册一个**动态模板**:
+
+| `ChartTemplateDef` 字段 | 作用 |
+|--------------------------|------|
+| `chart` | 公开名称(`"Grouped Bar Chart"`)— 与 `chart_spec.chartType` 匹配 |
+| `template` | 原生规范骨架 |
+| `channels` | 允许的编码 |
+| `markCognitiveChannel` | `position` / `length` / `area` / `color` — 零基线与拉伸类 |
+| `declareLayoutMode?` | 布局前的轴标志 |
+| `instantiate()` | 从 `InstantiateContext` 输出规范 |
+
+注册表:`vlTemplateDefs`、`ecTemplateDefs`、`cjsTemplateDefs`。查找:`vlGetTemplateDef(name)` 等。
+
+新后端仅实现阶段 3;前端与优化器保持不变。见[扩展后端](/documentation/adding-a-backend)。
+
+---
+
+# §6 输入
+
+| 部分 | API | 指定内容 |
+|------|-----|-----------|
+| 原始数据 | `data` | 供解析器与布局使用的行表 |
+| **dataSpec** | `semantic_types` | 字段含义;在同一数据集上的多张图表间复用 |
+| **chartSpec** | `chart_spec` | `chartType` + `encodings`;探索时易于编辑 |
+
+LLM 智能体通常一次性推断 `semantic_types`,然后迭代 `chart_spec`:在同一语义层上从折线 → 热力图 → 分组柱 → 瀑布 → 旭日图。
+
+---
+
+# §7 溢出与警告
+
+当离散基数超过画布预算时,优化器会过滤数据并附加 `ChartWarning` 元数据,而不是渲染不可读的图表。策略优先级与 `_warnings` 检查见 [API 参考 §8](/documentation/api-reference#8-overflow-and-warnings)。
+
+---
+
+# §8 仓库布局
+
+```text
+packages/flint-js/src/
+├── core/ resolve-semantics, field-semantics, compute-layout, type-registry, types
+├── vegalite/ Stage 3 — Vega-Lite templates + assembleVegaLite
+├── echarts/ Stage 3 — ECharts templates + assembleECharts
+├── chartjs/ Stage 3 — Chart.js templates + assembleChartjs
+└── test-data/ gallery fixtures (TEST_GENERATORS)
+
+packages/flint-py/ Python port preview (package planned later)
+site/ demo site (gallery, editor, documentation)
+```
+
+---
+
+# §9 相关
+
+- [概览](/documentation/overview) — 动机与规范示例
+- [API 参考](/documentation/api-reference) — `ChartAssemblyInput`、assembler、options
+- [语义类型](/documentation/semantic-types) — 类型层次与解析规则
+- [自动布局算法](/documentation/layout-model) — 拉伸与分面模型
+- [扩展图表模板](/documentation/adding-a-chart-template) — 扩展阶段 3
diff --git a/docs/zh-CN/color-decisions.md b/docs/zh-CN/color-decisions.md
new file mode 100644
index 00000000..447bf5c8
--- /dev/null
+++ b/docs/zh-CN/color-decisions.md
@@ -0,0 +1,207 @@
+# 颜色决策
+
+Flint 将**使用何种颜色比例尺**(分类型、顺序型或发散型)与**各后端如何渲染**分离。颜色逻辑分两层运行:
+
+1. **Phase 0 — 语义解析** 根据字段语义类型与数据,为每个通道分配 `ChannelSemantics.colorScheme` 建议(Vega-Lite 直接使用)。
+2. **`decideColorMaps()`**(位于 `core/color-decisions.ts`)将语义 + 编码转为与后端无关的 `ColorDecision` 记录(ECharts 与 Chart.js 使用,随后在本地选取具体十六进制调色板)。
+
+两层都不输出 Vega-Lite 或 ECharts 语法——仅输出抽象方案*类型*、可选显式方案 id、类别数量与发散中点。
+
+---
+
+## 在流水线中的位置
+
+```
+resolveChannelSemantics() Phase 0
+ └── cs.colorScheme { type, scheme, domainMid? }
+ │
+ ├─► Vega-Lite assemble
+ │ buildVLEncodings() copies scheme → encoding.scale.scheme
+ │ (VL built-in scheme names: category10, viridis, redblue, …)
+ │
+ └─► ECharts / Chart.js assemble
+ decideColorMaps() → ColorDecisionResult
+ └── pickEChartsPalette() / pickChartJsPalette()
+ └── hex color arrays on series / legend
+```
+
+| 后端 | 颜色入口 | 调色板来源 |
+|---------|-------------------|----------------|
+| Vega-Lite | `vegalite/assemble.ts` 中的 `ChannelSemantics.colorScheme` | `getRecommendedColorScheme()` → [Vega scheme names](https://vega.github.io/vega/docs/schemes/) |
+| ECharts | `decideColorMaps()` → `context.colorDecisions` | `echarts/colormap.ts` — `cat10`、`cat20`、`viridis`、`RdBu` |
+| Chart.js | `decideColorMaps()` → `context.colorDecisions` | `chartjs/colormap.ts` — 相同 id,Chart.js 调优的十六进制值 |
+
+ECharts 与 Chart.js 在组装期间调用一次 `decideColorMaps()`,并将结果挂到 `InstantiateContext.colorDecisions`。模板与 `instantiate-spec.ts` 读取该对象;它们不会重新推导方案族。
+
+---
+
+## Phase 0:语义颜色提示
+
+在 `resolveChannelSemantics()` 期间,承载颜色的通道(`color`,有时还有 `group`)会在 `ChannelSemantics` 上获得 `colorScheme`:
+
+```ts
+interface ColorSchemeRecommendation {
+ scheme: string; // e.g. 'tableau10', 'viridis', 'redblue'
+ type: 'categorical' | 'sequential' | 'diverging';
+ domainMid?: number;
+ reason?: string;
+}
+```
+
+生产路径:
+
+1. `resolveColorSchemeHint(semanticType, annotation, values)` — 根据类型注册表与数据范围分类发散型、顺序型与分类型。
+2. `getRecommendedColorScheme(...)`(位于 `core/semantic-types.ts`)— 从内部 `colorSchemes` 注册表选取具体 Vega-Lite 方案名。
+
+示例:
+
+| 语义类型 | 典型提示 | 示例方案 |
+|---------------|--------------|----------------|
+| `Country`、`Category` | categorical | 按基数选 `tableau10` / `tableau20` |
+| `Quantity`、`Temperature` | sequential | `viridis`、`reds` 等 |
+| `Percentage`、`Correlation`(跨 ±) | diverging | `redblue` 与 `domainMid` |
+
+Vega-Lite 编码构建随后应用:
+
+- 若用户设置了 `encoding.scheme` 且不为 `'default'`,则使用该值,否则
+- 对发散比例尺使用 `cs.colorScheme.scheme` 与 `domainMid`。
+
+类型如何馈入这些提示见[语义类型](/documentation/semantic-types)。
+
+---
+
+## 核心:`decideColorMaps()`
+
+**文件:** `packages/flint-js/src/core/color-decisions.ts`
+
+### 输入
+
+```ts
+interface DecideColorMapsContext {
+ chartType: string;
+ encodings: Record;
+ channelSemantics: Record;
+ table: any[];
+ background?: 'light' | 'dark'; // reserved
+}
+```
+
+### 输出
+
+```ts
+interface ColorDecisionResult {
+ color?: ColorDecision;
+ group?: ColorDecision;
+ fill?: ColorDecision; // reserved
+ stroke?: ColorDecision; // reserved
+}
+
+interface ColorDecision {
+ channel: 'color' | 'group' | 'fill' | 'stroke';
+ schemeType: 'categorical' | 'sequential' | 'diverging';
+ schemeId?: string; // set when user passes encoding.scheme
+ divergingMidpoint?: number;
+ categoryCount?: number; // distinct values in the color field
+ primary: boolean; // true for color / group
+ dataDriven: boolean;
+}
+```
+
+仅有绑定字段的通道会得到决策。当前评估 **`color` 与 `group`**;`fill` / `stroke` 为保留。
+
+### 每通道算法
+
+`decideColorForChannel()` 按顺序执行:
+
+1. **显式方案** — 若设置了 `encoding.scheme` 且不为 `'default'`,则透传 `schemeId`。从 `ChannelSemantics` 推断 `schemeType`(core 不校验 id;后端在其注册表中查找)。
+
+2. **语义驱动类型** — `decideSchemeTypeFromChannel()` 读取 `cs.colorScheme` 与编码/语义上下文:
+
+| 条件 | `schemeType` |
+|-----------|--------------|
+| `colorScheme.type === 'diverging'` | `diverging`(+ `domainMid` 作为中点) |
+| `colorScheme.type === 'sequential'` | `sequential` |
+| `colorScheme.type === 'categorical'` + 语义 `Rank` | `sequential`(在连续色带上表示秩) |
+| `colorScheme.type === 'categorical'` + `color` 上 `temporal` | `sequential`(避免将日期当作离散类别) |
+| `colorScheme.type === 'categorical'`(默认) | `categorical` |
+| 无提示 + 语义 `Correlation` | `diverging`,中点 `0` |
+| 无提示 + 编码 `quantitative` 或 `temporal` | `sequential` |
+| 回退 | `categorical` |
+
+3. **基数** — `countDistinctValues(table, field)` → `categoryCount`,供后端调色板尺寸使用(例如 `cat10` 与 `cat20`)。
+
+Core 在自动路径上**有意不**选取默认 `schemeId`。除非用户覆盖了 `scheme`,后端根据 `schemeType` + `categoryCount` 选择调色板。
+
+---
+
+## 后端调色板注册表
+
+### ECharts — `echarts/colormap.ts`
+
+内置映射:`cat10`、`cat20`、`viridis`、`RdBu`。每项包含 `type`、`supportsDiscrete`、`supportsContinuous`、`maxCategories`、`colorblindSafe` 与 `colors: string[]` 数组。
+
+**`pickEChartsPalette(decision)`**
+
+1. 若设置了 `decision.schemeId` → `getPaletteForScheme(id)`。
+2. 否则按 `decision.schemeType` 过滤映射:
+ - **categorical** — 最小的 `maxCategories` ≥ `categoryCount`(优先 `cat10` / `cat20`)。
+ - **sequential** — 首个支持连续的映射(通常为 `viridis`)。
+ - **diverging** — `diverging: true` 的映射(通常为 `RdBu`)。
+3. 回退 → ECharts 模板的 `DEFAULT_COLORS`。
+
+**`getPaletteForScheme(id)`** — 按 id 查找(不区分大小写);模板(Treemap、Heatmap、Graph 等)在需要直接取色时使用。
+
+### Chart.js — `chartjs/colormap.ts`
+
+结构与选择策略与 ECharts 相同,十六进制默认值为 Chart.js 调优。回退 → `cat10`。
+
+### Vega-Lite
+
+不调用 `decideColorMaps()`。方案为 Phase 0 解析的**名称**(`category10`、`tableau20`、`viridis`、`redblue` 等),在 `buildVLEncodings()` 期间写入 `encoding.scale.scheme`。
+
+---
+
+## 用户覆盖
+
+在 `chart_spec.encodings` 的任意颜色编码上设置 `scheme`:
+
+```json
+"encodings": {
+ "color": { "field": "region", "scheme": "viridis" }
+}
+```
+
+| 后端 | 效果 |
+|---------|--------|
+| Vega-Lite | `scale.scheme = "viridis"`(Vega 内置名) |
+| ECharts / Chart.js | `ColorDecision.schemeId = "viridis"` → 在后端注册表中查找调色板 |
+
+为在 ECharts 与 Chart.js 间获得可移植结果,请使用目标后端注册表中存在的 id(`cat10`、`viridis`、`RdBu` 等)。Vega-Lite 接受更广的 [Vega 方案目录](https://vega.github.io/vega/docs/schemes/)。
+
+---
+
+## 设计理由
+
+**决策与渲染分离** — 方案*族*与基数在 core(或 Vega-Lite 的 Phase 0)中一次性决定。十六进制数组与比例尺对象留在后端代码中,因此 ECharts 与 Chart.js 可在视觉上分叉而不复制语义逻辑。
+
+**共享语义** — 同一 `ChartAssemblyInput` 在 ECharts 与 Chart.js 上产生相同的 `schemeType` 与 `categoryCount`。仅每后端主题的调色板十六进制值不同。
+
+**优雅路径** — 显式 `encoding.scheme` 优先。否则语义与编码类型驱动方案族;后端始终有回退调色板。
+
+### 扩展点
+
+| 变更 | 位置 |
+|--------|--------|
+| 新语义 → 方案规则 | `getRecommendedColorScheme()` / `resolveColorSchemeHint()` |
+| 新方案族规则 | `decideSchemeTypeFromChannel()` |
+| 新 ECharts / Chart.js 调色板 | `ECHARTS_COLOR_MAPS` / `CHARTJS_COLOR_MAPS` |
+| 新颜色通道 | `ColorChannel` 联合类型 + `decideColorMaps()` 中的循环 |
+| Vega-Lite 统一路径 | 在 `vegalite/assemble.ts` 中调用 `decideColorMaps()`(当前未接入) |
+
+---
+
+## 相关
+
+- [语义类型](/documentation/semantic-types) — 类型注册表与 Phase 0 中的 `colorScheme` 提示
+- [架构](/documentation/architecture) — 完整编译流水线
+- [API 参考](/documentation/api-reference) — `ChartEncoding.scheme`
diff --git a/docs/zh-CN/design-semantics.md b/docs/zh-CN/design-semantics.md
new file mode 100644
index 00000000..5d50222f
--- /dev/null
+++ b/docs/zh-CN/design-semantics.md
@@ -0,0 +1,863 @@
+# 语义类型
+
+语义类型描述每个数据字段*代表什么*,而不仅仅是如何存储。它们告诉 Flint 字段应如何编码、格式化、聚合、排序和着色。编译器首先将每个字段的语义类型及可选注解解析为 `FieldSemantics`,然后将相关决策提升为各通道的 `ChannelSemantics`,用于布局和后端规范生成。
+
+---
+
+## 目录
+
+- [§1 概览](#1-overview)
+- [§2 类型层级](#2-type-hierarchy)
+ - [§2.1 分层类型系统](#21-tiered-type-system)
+ - [§2.2 第 0 层 — 族(Family)](#22-tier-0-families)
+ - [§2.3 第 1 层 — 类别(Category)](#23-tier-1-categories)
+ - [§2.4 第 2 层 — 具体类型](#24-tier-2-specific-types)
+ - [§2.5 层级作为 DAG](#25-the-hierarchy-as-a-dag)
+ - [§2.6 循环域类型](#26-cyclic-domain-types)
+ - [§2.7 LLM 注解策略](#27-llm-annotation-strategies)
+ - [§2.8 类型注册表](#28-type-registry)
+- [§3 字段注解](#3-field-annotations)
+ - [§3.1 为何元数据重要](#31-why-metadata-matters)
+ - [§3.2 SemanticAnnotation](#32-semanticannotation)
+ - [§3.3 哪些类型需要元数据?](#33-which-types-need-metadata)
+ - [§3.4 数值表示检测](#34-numeric-representation-detection)
+ - [§3.5 接受字符串或对象](#35-accepting-string-or-object)
+- [§4 编译流水线](#4-compilation-pipeline)
+ - [§4.1 四阶段概览](#41-four-stage-overview)
+ - [§4.2 字段与通道职责](#42-field-vs-channel-responsibilities)
+ - [§4.3 resolveFieldSemantics](#43-resolvefieldsemantics)
+ - [§4.4 resolveChannelSemantics](#44-resolvechannelsemantics)
+ - [§4.5 FieldSemantics 接口](#45-fieldsemantics-interface)
+ - [§4.6 ChannelSemantics 接口](#46-channelsemantics-interface)
+ - [§4.7 辅助类型](#47-supporting-types)
+ - [§4.8 布局与规范生成](#48-layout-and-spec-generation)
+ - [§4.9 缓存](#49-caching)
+- [§5 解析规则](#5-resolution-rules)
+ - [§5.1 格式与解析](#51-format-and-parsing)
+ - [§5.2 聚合默认值](#52-aggregation-defaults)
+ - [§5.3 比例尺、域与刻度](#53-scale-domain-and-ticks)
+ - [§5.4 坐标轴与标记](#54-axes-and-marks)
+ - [§5.5 发散与颜色](#55-diverging-and-color)
+- [§6 示例](#6-examples)
+ - [§6.1 收入柱状图](#61-revenue-bar-chart)
+ - [§6.2 温度折线图](#62-temperature-line-chart)
+ - [§6.3 排名 bump 图](#63-rank-bump-chart)
+ - [§6.4 带域的 Rating](#64-rating-with-domain)
+- [§7 相关文档](#7-related)
+
+---
+
+# §1 概览
+
+**语义类型**是一个命名标签,例如 `Revenue`、`Month` 或 `Rating`,用于告诉编译器如何处理字段。类型组织为三个层级,对应 Flint 的语义级别:
+
+| Flint 级别 | 代码层级 | 数量 | 决定内容 |
+|-------------|-----------|-------|---------|
+| L1 语义域 | **T0** Family | 6 | 解析器类别、编码族(temporal / measure / categorical / …) |
+| L2 语义族 | **T1** Category | 17 | 聚合角色、零点类别、格式类别、发散提示 |
+| L3 语义类型 | **T2** Specific | 46 | 精确格式、域、刻度策略、类型特定呈现 |
+
+LLM 或用户可在任意层级进行注解。结果会优雅降级而非失败:`Revenue`(T2)产生货币格式、求和聚合和对数比例尺提示;`Amount`(T1)仍获得货币类别和求和;`Measure`(T0)仍获得定量编码和有意义零点,但无格式前缀。
+
+**设计原则:**
+
+1. **语义类型是单一事实来源。** 编译上下文是(semanticType、dataValues、channel、markType)的确定性函数。无隐藏状态。
+2. **决策结构化,而非分散。** 一个构建器产生类型化上下文对象;下游代码读取这些对象,而非重新检查语义类型字符串。
+3. **先按字段,再按通道。** 格式和聚合是字段内在的;零点基线、反转和配色方案取决于通道和标记。
+4. **易于覆盖。** 每个决策都有类型推导的默认值;用户、模板或智能体可显式覆盖单个字段。
+5. **后端无关。** 上下文在翻译为 Vega-Lite、ECharts 或其他目标之前,描述抽象意图,如货币格式或反转坐标轴。
+6. **语义类型 + 可选元数据。** 有界比例尺、单位和自定义排序可能需要与类型字符串并行的结构化注解。
+
+关于语义解析在完整编译路径中的位置,请参阅[架构](/documentation/architecture)。
+
+---
+
+# §2 类型层级
+
+## §2.1 分层类型系统
+
+不同任务需要不同粒度的 specificity。三个层级让 LLM 选择合适的花费/质量权衡:
+
+| 层级 | 数量 | 用途 | LLM 成本 | 可视化配置质量 |
+|---|---|---|---|---|
+| **T0 — Family** | 6 | 最粗:编码类型和基本默认值 | 最低 — 可基于规则回退 | 正确编码,通用格式化 |
+| **T1 — Category** | 17 | 格式类别、聚合默认、零点基线、颜色类别 | 中等 — 小型封闭列表 | 良好格式化,合理默认值 |
+| **T2 — Specific** | 46 | 发散中点、域约束、刻度策略 | 较高 — 更大词汇表 | 完整编译上下文 |
+
+## §2.2 第 0 层 — 族(Family)
+
+由启发式推断的宽泛类别(无需 LLM):
+
+| T0 Family | 数据类型 | 默认可视化编码 | 决定内容 |
+|---|---|---|---|
+| **Temporal** | date/string | temporal | 时间轴、日期解析、时间排序 |
+| **Measure** | number | quantitative | 数值轴、aggregation=sum、有意义零点 |
+| **Discrete** | number | ordinal | 整数刻度、无聚合、任意零点 |
+| **Geographic** | number/string | geographic/nominal | 地图图层、地理编码 |
+| **Categorical** | string | nominal | 颜色/形状/分面、无轴排序 |
+| **Identifier** | number/string | nominal | 仅 tooltip,永不编码到轴/颜色 |
+
+T0 单独即可提供正确编码、基本聚合和零点基线类别。它无法捕获格式前缀/后缀、特定聚合、发散检测、域约束或比例尺提示。
+
+## §2.3 第 1 层 — 类别(Category)
+
+每个 T1 精确映射到一个 T0 family:
+
+| T0 Family | T1 Categories | T1 相对 T0 的增量 |
+|---|---|---|
+| **Temporal** | `DateTime`, `DateGranule`, `Duration` | 时点 vs 粒度 vs 跨度;temporal vs ordinal 编码 |
+| **Measure** | `Amount`, `Physical`, `Proportion`, `SignedMeasure`, `GenericMeasure` | 格式类别($、%、°)、聚合、发散检测 |
+| **Discrete** | `Rank`, `Score`, `Index` | 反转轴(Rank)、整数刻度、域提示 |
+| **Geographic** | `GeoCoordinate`, `GeoPlace` | 经纬度配对 vs 可地理编码名称 |
+| **Categorical** | `Entity`, `Coded`, `Binned` | 基数期望、分箱的顺序性 |
+| **Identifier** | `ID` | 永不聚合、永不编码 |
+
+**完整 T1 表:**
+
+| T1 Type | T0 Family | 可视化编码 | 决定内容 |
+|---|---|---|---|
+| `DateTime` | Temporal | temporal | 完整日期/时间解析、时间轴 |
+| `DateGranule` | Temporal | ordinal or temporal | Month/Year/Quarter — 顺序排序、规范顺序 |
+| `Duration` | Temporal | quantitative | 时间跨度格式化、sum/avg 聚合 |
+| `Amount` | Measure | quantitative | 货币前缀、sum、有意义零点 |
+| `Physical` | Measure | quantitative | 单位后缀、avg 聚合、Temperature 的任意零点 |
+| `Proportion` | Measure | quantitative | % 格式化、有界域、avg 聚合 |
+| `SignedMeasure` | Measure | quantitative | 发散中点(0)、有符号数据 |
+| `GenericMeasure` | Measure | quantitative | 无特殊格式,sum/avg 来自字段名 |
+| `Rank` | Discrete | ordinal | 反转轴、整数刻度、不可聚合 |
+| `Score` | Discrete | quantitative | 有界域、整数刻度、avg 聚合 |
+| `Index` | Discrete | ordinal/nominal | 行号 — 不可聚合 |
+| `GeoCoordinate` | Geographic | quantitative | 固定域(lat/lon)、地图投影 |
+| `GeoPlace` | Geographic | nominal | 可地理编码名称、choropleth/symbol-map |
+| `Entity` | Categorical | nominal | 高基数、适合 tooltip |
+| `Coded` | Categorical | nominal | 低基数、离散颜色(Status、Type、Boolean、Direction) |
+| `Binned` | Categorical | ordinal | 预分箱区间、顺序轴 |
+| `ID` | Identifier | nominal | 永不聚合、仅 tooltip |
+
+## §2.4 第 2 层 — 具体类型
+
+每个 T2 精确映射到一个 T1。清单仅保留**相对其 T1 父级会改变编译行为**的类型。领域特定的发散中点(如 pH=7 或 NPS=0)来自 `intrinsicDomain` 或类型内在逻辑,而非专用 T2 类型。
+
+| T1 Category | T2 Specific Types |
+|---|---|
+| `DateTime` | DateTime, Date, Time, Timestamp |
+| `DateGranule` | Year, Quarter, Month, Week, Day, Hour, YearMonth, YearQuarter, YearWeek, Decade |
+| `Duration` | Duration |
+| `Amount` | Amount, Price, Revenue, Cost |
+| `Physical` | Quantity, Temperature |
+| `Proportion` | Percentage |
+| `SignedMeasure` | Profit, PercentageChange, Sentiment, Correlation |
+| `GenericMeasure` | Count, Number |
+| `Rank` | Rank |
+| `Score` | Score, Rating |
+| `Index` | Index |
+| `GeoCoordinate` | Latitude, Longitude |
+| `GeoPlace` | Country, State, City, Region, ZipCode, Address |
+| `Entity` | PersonName, Company, Product, Category, Name, String, Unknown |
+| `Coded` | Status, Type, Boolean, Direction |
+| `Binned` | Range, AgeGroup |
+| `ID` | ID |
+
+**已移除类型**(从 `TYPE_REGISTRY` 和 `SemanticTypes` 中删除;未知字符串回退到 `UNKNOWN_ENTRY`):
+
+| 已移除 T2 | 改用 | 理由 |
+|---|---|---|
+| TimeRange | Duration | 相同编译 |
+| Distance, Area, Volume, Weight, Speed | Quantity / `Physical` T1 | 单位来自注解 |
+| Rate | Percentage | 相同格式 + 聚合 |
+| Ratio | Number | 开放域、小数格式 |
+| Level | Score | 相同有界/avg 编译 |
+| Coordinates | Latitude + Longitude | 配对歧义 |
+| Location | Country / State / City | 通用回退 |
+| Username, Email, Brand, Department | PersonName / Company / Name | 相同 nominal 编译 |
+| Binary, Code | Boolean / Status | 相同 categorical 编译 |
+| Bucket | Range | 相同编译 |
+| SKU | ID | 相同标识符角色 |
+
+**T2 相对 T1 的增量:** `Revenue` vs `Price`(可加性 vs 强度型);`Temperature` vs `Quantity`(条件发散);`Month` vs `Year`(cyclic(12) vs open);`Sentiment` vs `Profit` vs `Correlation`(固有 vs 条件发散)。
+
+## §2.5 层级作为 DAG
+
+```text
+T0 Family T1 Category T2 Specific
+───────── ─────────── ──────────────────────
+
+Temporal ─────┬── DateTime ──────────── DateTime, Date, Time, Timestamp
+ ├── DateGranule ───────── Year, Quarter, Month, Week, Day, Hour,
+ │ YearMonth, YearQuarter, YearWeek, Decade
+ └── Duration ─────────── Duration
+
+Measure ──────┬── Amount ────────────── Amount, Price, Revenue, Cost
+ ├── Physical ─────────── Quantity, Temperature
+ ├── Proportion ────────── Percentage
+ ├── SignedMeasure ─────── Profit, PercentageChange, Sentiment, Correlation
+ └── GenericMeasure ────── Count, Number
+
+Discrete ─────┬── Rank ─────────────── Rank
+ ├── Score ────────────── Score, Rating
+ └── Index ────────────── Index
+
+Geographic ───┬── GeoCoordinate ────── Latitude, Longitude
+ └── GeoPlace ─────────── Country, State, City, Region, ZipCode, Address
+
+Categorical ──┬── Entity ───────────── PersonName, Company, Product, Category, Name, String, Unknown
+ ├── Coded ────────────── Status, Type, Boolean, Direction
+ └── Binned ───────────── Range, AgeGroup
+
+Identifier ───┴── ID ───────────────── ID
+```
+
+解析沿 T2 → T1 → T0 遍历,应用 progressively finer 规则,并在某层级无特定决策时回退:
+
+```typescript
+function resolveFieldSemantics(annotation, fieldName, values) {
+ const { semanticType } = normalizeAnnotation(annotation);
+ const t2 = T2_REGISTRY[semanticType];
+ const t1 = t2?.t1 ?? T1_REGISTRY[semanticType];
+ const t0 = t1?.t0 ?? T0_REGISTRY[semanticType];
+
+ // T0: encoding, agg role, zero class (always available)
+ // T1: format class, agg default, diverging class (if T1 or finer)
+ // T2: format detail, domain, ticks, interpolation (if T2)
+ return mergeContext(t0Defaults, t1Refinements, t2Specifics);
+}
+```
+
+## §2.6 循环域类型
+
+具有环绕域的类型需要规范排序、不在循环外外推、循环调色板和 radar/polar 提示:
+
+| Type | Cycle | Values | 可视化关注点 |
+|---|---|---|---|
+| Month | 12 | Jan–Dec 或 1–12 | 轴不应显示 "13";颜色环绕 |
+| Day (weekday) | 7 | Mon–Sun | 同上 |
+| Hour | 24 | 0–23 | 圆形图表自然 |
+| Direction | 8/16+ | N, NE, E, … | polar/radar 自然 |
+| Quarter | 4 | Q1–Q4 | 轴排序 |
+
+## §2.7 LLM 注解策略
+
+| 策略 | 使用的类型 | 适用场景 | LLM 提示规模 |
+|---|---|---|---|
+| **完整 T2** | 所有具体类型 | 高价值仪表板 | 最大(约 46 种类型) |
+| **仅 T1** | 类别级 | 批量注解、成本敏感 | 中等(约 17 种类型) |
+| **仅 T0** | 族级 | 快速预览、基于规则回退 | 最小(约 6 种类型) |
+| **混合** | 关键字段用 T2,其余用 T1 | 典型交互会话 | 自适应 |
+
+**混合策略示例** — 图表关键字段用 T2,其余用 T1:
+
+```json
+{
+ "revenue": { "semantic_type": "Revenue", "unit": "USD" },
+ "month": { "semantic_type": "Month" },
+ "product_category": { "semantic_type": "Coded" },
+ "customer_name": { "semantic_type": "Entity" },
+ "customer_age": { "semantic_type": "GenericMeasure" },
+ "region": { "semantic_type": "GeoPlace" },
+ "order_date": { "semantic_type": "DateTime" },
+ "satisfaction": { "semantic_type": "Score", "intrinsic_domain": [1, 5] }
+}
+```
+
+## §2.8 类型注册表
+
+层级控制*哪些*规则在何种粒度触发。每种类型还携带**五个正交维度**,直接驱动可视化属性。这些维度与类型的层级位置一起存在于 `TypeRegistryEntry` 中:
+
+| 维度 | 取值 | 控制内容 |
+|---|---|---|
+| **Vis encoding candidates** | `quantitative`, `ordinal`, `nominal`, `temporal`(偏好顺序) | 轴类型、比例尺类型、标记兼容性、排序 |
+| **Aggregation role** | `additive`, `intensive`, `signed-additive`, `dimension`, `identifier` | 聚合函数、group-by、仅 tooltip |
+| **Domain shape** | `open`, `bounded`, `fixed`, `cyclic` | 域钳制、刻度、外推、polar 提示 |
+| **Diverging nature** | `none`, `conditional`, `inherent` | 顺序 vs 发散颜色、中点、图例 |
+| **Format class** | `currency`, `percent`, `unit-suffix`, `date`, `time`, `integer`, `plain` | 轴/tooltip 格式、前缀/后缀、精度 |
+
+**典型类型**(层级位置 + 维度值):
+
+| Type (T2) | T1 | T0 | Vis encoding | Agg role | Domain | Diverging | Format |
+|---|---|---|---|---|---|---|---|
+| Month | DateGranule | Temporal | ordinal, temporal | dimension | cyclic (12) | none | date |
+| Year | DateGranule | Temporal | temporal, ordinal | dimension | open | none | integer |
+| Rating | Score | Discrete | quantitative, ordinal | intensive | bounded [1,N] | conditional | integer |
+| Temperature | Physical | Measure | quantitative | intensive | open | conditional | unit-suffix |
+| Quantity | Physical | Measure | quantitative | intensive | open, ≥0 | none | unit-suffix |
+| Sentiment | SignedMeasure | Measure | quantitative | signed-additive | bounded [-1,1] | inherent | plain |
+| Correlation | SignedMeasure | Measure | quantitative | signed-additive | bounded [-1,1] | inherent | plain |
+| Profit | SignedMeasure | Measure | quantitative | signed-additive | open | conditional | currency |
+| PercentageChange | SignedMeasure | Measure | quantitative | signed-additive | open | conditional | percent |
+| Revenue | Amount | Measure | quantitative | additive | open, ≥0 | none | currency |
+| Price | Amount | Measure | quantitative | intensive | open, ≥0 | none | currency |
+| Percentage | Proportion | Measure | quantitative | intensive | bounded [0,1] or [0,100] | none | percent |
+| Count | GenericMeasure | Measure | quantitative | additive | open, ≥0 | none | integer |
+| Country | GeoPlace | Geographic | nominal | dimension | open | none | plain |
+| Latitude | GeoCoordinate | Geographic | quantitative | dimension | fixed [-90,90] | none | plain |
+| Rank | Rank | Discrete | ordinal | dimension | open | none | integer |
+| Status | Coded | Categorical | nominal | dimension | fixed | none | plain |
+| Direction | Coded | Categorical | nominal | dimension | cyclic (8/16) | none | plain |
+
+在 T1,构建器继承类别的维度值。在 T2,应用具体覆盖。在 T0,应用保守默认值。下游代码读取解析后的 `FieldSemantics` / `ChannelSemantics`,而非直接读取层级或维度。部分维度值依赖数据,例如根据 distinct-value 数量选择 `Rating` 编码;该消歧在 `resolveFieldSemantics` 中完成,而非注册表中。
+
+```typescript
+interface TypeRegistryEntry {
+ t0: T0Family;
+ t1: T1Category;
+ visEncodings: VisCategory[];
+ aggRole: AggRole;
+ domainShape: DomainShape;
+ diverging: DivergingClass;
+ formatClass: FormatClass;
+ zeroBaseline: ZeroBaseline;
+ zeroPad: number;
+}
+```
+
+---
+
+# §3 字段注解
+
+## §3.1 为何元数据重要
+
+裸类型字符串如 `"Rating"` 存在歧义:比例尺是 1–5、1–10 还是 0–100?其他有界或带单位的类型也有类似缺口:
+
+| Type | 缺失内容 | 为何重要 |
+|---|---|---|
+| **Rating** / **Score** | 比例尺范围 | 刻度、域、零点决策 |
+| **Percentage** | 表示(0–1 vs 0–100) | 格式:`.1%` vs `.1f` + "%" |
+| **Temperature** | 单位(°C、°F、K) | 后缀、发散中点(0°C vs 32°F) |
+| **Physical measures** | 单位(kg、km、mph) | 格式后缀 |
+| **Price / Revenue / Cost** | 货币(USD、EUR) | 格式前缀($、€) |
+| **Duration** | 单位(seconds、hours) | 显示策略 |
+| **Ordinal categoricals** | 自定义排序 | 非字母顺序(severity、size) |
+
+开放式度量(`Count`、`Revenue`、`Rank`)和 nominal 字段(`Country`、`Status`)通常无需元数据。
+
+## §3.2 SemanticAnnotation
+
+```typescript
+/**
+ * Enriched semantic annotation for a single field.
+ * Only `semanticType` is required. Compact form: bare string equals
+ * `{ semanticType: "..." }`.
+ */
+interface SemanticAnnotation {
+ /** Semantic type string (e.g. 'Rating', 'Temperature', 'Price') */
+ semanticType: string;
+
+ /**
+ * Intrinsic domain for bounded/scaled types.
+ * Drives domainConstraint, exactTicks, zeroBaseline, diverging midpoint.
+ * NOT for open-ended measures (Revenue, Count, Temperature).
+ */
+ intrinsicDomain?: [number, number];
+
+ /**
+ * Unit of measurement — cosmetic when present; omit if mixed units.
+ * Drives format prefix/suffix and diverging midpoint (°C → 0, °F → 32).
+ */
+ unit?: string;
+
+ /**
+ * Canonical sort order for domain-specific ordinals.
+ * Well-known types (Month, DayOfWeek) need not provide this.
+ */
+ sortOrder?: string[];
+}
+```
+
+## §3.3 哪些类型需要元数据?
+
+| Type | `intrinsicDomain` | `unit` | `sortOrder` | 原因 |
+|---|---|---|---|---|
+| **Rating** | 是 — [1,5]、[1,10]、[0,100] | 否 | 否 | 比例尺决定刻度、域、零点 |
+| **Score** | 是 — [0,100]、[0,10] | 否 | 否 | 同 Rating |
+| **Percentage** | 半 — 从数据推断 | 否 | 否 | 表示影响格式 |
+| **Temperature** | 否 | 可选 — °C、°F、K | 否 | 后缀 + 发散提示 |
+| **Physical**(任意) | 否 | 可选 | 否 | 仅后缀 |
+| **Duration** | 否 | 可选 | 否 | 显示提示 |
+| **Price / Revenue / Cost / Amount** | 否 | 可选 — USD、EUR | 否 | 货币前缀 |
+| **Latitude / Longitude** | 固定(类型内在) | 否 | 否 | 无需注解 |
+| Count, Quantity, Rank, ID, … | 否 | 否 | 否 | 无歧义 |
+| **Ordinal categoricals**(Severity、Size) | 否 | 否 | **是** | 领域特定顺序 |
+| Well-known ordinals (Month, DayOfWeek) | 否 | 否 | 否 | 内置顺序 |
+| Nominal categoricals | 否 | 否 | 否 | 无固有顺序 |
+| **Sentiment, Correlation, Profit** | 否 | 可选 currency | 否 | 中点来自类型 |
+| **Domain-specific diverging**(pH、NPS) | 是 — 如 [0, 14] | 否 | 否 | 中点来自域 |
+
+## §3.4 数值表示检测
+
+部分类型以不同数值编码出现。构建器在确定字段上下文时解析这些表示:
+
+| Type | Representations | Detection |
+|---|---|---|
+| **Percentage** | 0–1 分数 vs 0–100 整数 | `max(data) ≤ 1` → fractional;否则 whole;或 `intrinsicDomain` |
+| **Timestamp** | Unix s、Unix ms、ISO string | Magnitude >1e12 → ms;>1e9 → s;string → parse |
+| **Month / Day** | Numeric vs abbreviated vs full name | Data type + pattern matching |
+| **Boolean** | true/false、0/1、Yes/No | Data type + distinct values |
+
+**Percentage 影响:**
+
+| Concern | Fractional (0–1) | Whole-number (0–100) |
+|---|---|---|
+| Format | `.1%`(d3 ×100) | `.0f` + suffix `%` |
+| Domain | [0, 1] | [0, 100] |
+| Ticks | 0, 0.25, 0.5, 0.75, 1.0 | 0, 25, 50, 75, 100 |
+
+优先级:(1)显式 `intrinsicDomain`,(2)数据检查,(3)保守默认。若至少 80% 的绝对值 ≤1,则将字段视为 fractional。
+
+## §3.5 接受字符串或对象
+
+```typescript
+function normalizeAnnotation(
+ input: string | SemanticAnnotation
+): SemanticAnnotation {
+ if (typeof input === 'string') {
+ return { semanticType: input };
+ }
+ return input;
+}
+```
+
+图表输入中的 `semantic_types` 接受 `Record`。注解元数据在 `resolveFieldSemantics` 期间流入 `FieldSemantics`:`intrinsicDomain` → 域、刻度、零点和发散中点;`unit` → 格式前缀/后缀;`sortOrder` → `canonicalOrder` 和 ordinal 编码。
+
+---
+
+# §4 编译流水线
+
+## §4.1 四阶段概览
+
+| Stage | Function | Input → Output | Concern |
+|-------|----------|---------------|---------|
+| **1. Field Semantics** | `resolveFieldSemantics()` | Annotation + data → `FieldSemantics` | 这个字段*是什么*? |
+| **2. Channel Semantics** | `resolveChannelSemantics()` | FieldSemantics + channel → `ChannelSemantics` | 在此通道上应如何渲染? |
+| **3. Layout** | `computeLayout()` | ChannelSemantics + data → `LayoutResult` | 多大?过滤什么? |
+| **4. Spec Generation** | `assembleVegaLite()` 等 | ChannelSemantics + template → backend spec | 后端特定输出 |
+
+`ChannelSemantics` 是**中间表示(IR)**:扁平、目标无关的记录,将上游语义与布局及所有后端(Vega-Lite、ECharts、Chart.js)解耦。
+
+```text
+┌──────────────────────────────────────────────────────────────────────┐
+│ Stage 1: Field Semantics │
+│ resolveFieldSemantics(annotation, fieldName, values) │
+│ → FieldSemantics (format, agg, domain, ordering) │
+├──────────────────────────────────────────────────────────────────────┤
+│ Stage 2: Channel Semantics │
+│ resolveChannelSemantics(encodings, data, semanticTypes, converted) │
+│ → ChannelSemantics (encoding type, color, ticks, reversal, …) │
+├──────────────────────────────────────────────────────────────────────┤
+│ IR boundary: ChannelSemantics (flat, target-agnostic) │
+├──────────────────────────────────────────────────────────────────────┤
+│ Stage 3: Layout — computeLayout(), filterOverflow() │
+├──────────────────────────────────────────────────────────────────────┤
+│ Stage 4: Spec Generation — assembleVegaLite / ECharts / … │
+│ finalize zero-baseline, template.instantiate, apply layout │
+└──────────────────────────────────────────────────────────────────────┘
+```
+
+阶段边界有意收窄:`convertTemporalData()` 在 Stage 2 之前运行一次;`FieldSemantics` 仅内部用于 Stage 2;零点基线最终确定等到 Stage 4,因为需要模板 mark type。
+
+## §4.2 字段与通道职责
+
+| Decision | Source | Output field |
+|---|---|---|
+| **来自字段语义(数据身份)** | | |
+| Semantic annotation | `resolveFieldSemantics()` | `ChannelSemantics.semanticAnnotation` |
+| Number format | `resolveFieldSemantics()` → `resolveFormat()` | `ChannelSemantics.format` |
+| Tooltip format | `resolveFieldSemantics()` → `resolveFormat()` | `ChannelSemantics.tooltipFormat` |
+| Aggregation default | `resolveFieldSemantics()` → `resolveAggregationDefault()` | `ChannelSemantics.aggregationDefault` |
+| Scale type | `resolveFieldSemantics()` → `resolveScaleType()` | `ChannelSemantics.scaleType` |
+| Domain constraint | `resolveFieldSemantics()` → `resolveDomainConstraint()` | `ChannelSemantics.domainConstraint` |
+| Canonical order | `resolveFieldSemantics()` → `resolveCanonicalOrder()` | 从 `FieldSemantics` 提升 |
+| Cyclic ordering | `resolveFieldSemantics()` → `resolveCyclic()` | `ChannelSemantics.cyclic` |
+| Sort direction | `resolveFieldSemantics()` → `resolveSortDirection()` | `ChannelSemantics.sortDirection` |
+| Zero baseline class | `resolveFieldSemantics()` → `resolveZeroBaseline()` | Stage 4 的内部提示 |
+| Binning suggested | `resolveFieldSemantics()` → `resolveBinningSuggested()` | `ChannelSemantics.binningSuggested` |
+| **通道特定(可视化)** | | |
+| Encoding type (Q/O/N/T) | `resolveEncodingTypeDecision()` | `ChannelSemantics.type` |
+| Zero-baseline boolean | `computeZeroDecision()`(Stage 4) | `ChannelSemantics.zero` |
+| Color scheme | `getRecommendedColorSchemeWithMidpoint()` | `ChannelSemantics.colorScheme` |
+| Temporal format | `resolveTemporalFormat()` | `ChannelSemantics.temporalFormat` |
+| Ordinal sort order | `inferOrdinalSortOrder()` | `ChannelSemantics.ordinalSortOrder` |
+| Nice rounding | `resolveNice()` | `ChannelSemantics.nice` |
+| Tick constraint | `resolveTickConstraint()` | `ChannelSemantics.tickConstraint` |
+| Axis reversal | `resolveReversed()` | `ChannelSemantics.reversed` |
+| Interpolation | `resolveInterpolation()` | `ChannelSemantics.interpolation` |
+| Stackable | `resolveStackable()` | `ChannelSemantics.stackable` |
+
+## §4.3 resolveFieldSemantics
+
+```typescript
+function resolveFieldSemantics(
+ annotation: string | SemanticAnnotation,
+ fieldName: string,
+ values: any[],
+): FieldSemantics;
+```
+
+内部流程(仅字段内在):
+
+- `normalizeAnnotation(annotation)` → semantic type + 可选元数据
+- `resolveTiers(semanticType)` → T0/T1 用于规则选择
+- `resolveDefaultVisType(semanticType, values)` → 带数据消歧的编码
+- `resolveFormat(semanticType, unit, fieldName, values)` → `FormatSpec` + tooltip;`detectPrecision(values)` 用于数据驱动小数位
+- `resolveAggregationDefault(semanticType)` → sum / average / undefined
+- `resolveZeroBaseline(semanticType, domain)` → meaningful / arbitrary / contextual
+- `resolveScaleType(semanticType, values)` → linear / log / sqrt(依赖数据)
+- `resolveDomainConstraint(semanticType, domain, values)` → annotation > type-intrinsic > data-inferred
+- `resolveCanonicalOrder(semanticType, sortOrder, values)` → annotation 或内置
+- `resolveCyclic(semanticType)` → boolean
+- `resolveSortDirection(semanticType)` → ascending / descending
+- `resolveBinningSuggested(semanticType, domain, values)` → boolean
+
+通道级函数(`resolveTickConstraint`、`resolveReversed`、`resolveNice`、color scheme、interpolation、stackable)从同一模块导出,由 Stage 2 调用。
+
+## §4.4 resolveChannelSemantics
+
+```typescript
+// Stage 2 entry point → Record
+function resolveChannelSemantics(encodings, data, semanticTypes, convertedData?) {
+ for each channel:
+ fc = resolveFieldSemantics(normalizeAnnotation(semanticTypes[field]), field, values)
+ cs = {
+ field, semanticAnnotation: fc.semanticAnnotation,
+ type: resolveEncodingType(...),
+ // promoted from FieldSemantics:
+ format, tooltipFormat, aggregationDefault, scaleType,
+ domainConstraint, canonicalOrder, cyclic, sortDirection, binningSuggested,
+ // channel-resolved:
+ nice: resolveNice(semanticType, domainShape),
+ tickConstraint: resolveTickConstraint(semanticType, domain, values),
+ reversed: resolveReversed(semanticType),
+ colorScheme: resolveColorScheme(semanticType, annotation, values),
+ temporalFormat: resolveTemporalFormat(...),
+ ordinalSortOrder: inferOrdinalSortOrder(...),
+ interpolation: resolveInterpolation(semanticType),
+ stackable: resolveStackable(semanticType),
+ }
+ return Record
+}
+```
+
+Stage 2 **不**设置 `zero`。Stage 4 调用 `computeZeroDecision()` 并传入 mark type:bar → 为长度完整性包含零;scatter → 数据拟合。
+
+## §4.5 FieldSemantics 接口
+
+```typescript
+/**
+ * Field-intrinsic properties — semantic type, annotation, and data.
+ * NOT channel-dependent. Computed in Stage 1.
+ */
+interface FieldSemantics {
+ semanticAnnotation: SemanticAnnotation;
+ defaultVisType: 'quantitative' | 'ordinal' | 'nominal' | 'temporal';
+ format: FormatSpec;
+ tooltipFormat?: FormatSpec;
+ aggregationDefault?: 'sum' | 'average';
+ zeroBaseline: ZeroBaseline | 'unknown';
+ scaleType?: 'linear' | 'log' | 'sqrt' | 'symlog';
+ domainConstraint?: DomainConstraint;
+ canonicalOrder?: string[];
+ cyclic: boolean;
+ sortDirection: 'ascending' | 'descending';
+ binningSuggested: boolean;
+}
+```
+
+**不在** `FieldSemantics` 上的属性(依赖通道/标记):`nice`、`tickConstraint`、`reversed`、`interpolation`、`stackable`、`colorScheme`、`zero`、`temporalFormat`、`ordinalSortOrder`。
+
+## §4.6 ChannelSemantics 接口
+
+```typescript
+/** Flat IR — sole public interface for layout, templates, and all backends. */
+interface ChannelSemantics {
+ field: string;
+ semanticAnnotation: SemanticAnnotation;
+ type: 'quantitative' | 'nominal' | 'ordinal' | 'temporal';
+ format?: FormatSpec;
+ tooltipFormat?: FormatSpec;
+ temporalFormat?: string;
+ aggregationDefault?: 'sum' | 'average';
+ zero?: ZeroDecision; // finalized in Stage 4
+ scaleType?: 'linear' | 'log' | 'sqrt' | 'symlog';
+ nice?: boolean;
+ domainConstraint?: DomainConstraint;
+ tickConstraint?: TickConstraint;
+ ordinalSortOrder?: string[];
+ cyclic?: boolean;
+ reversed?: boolean;
+ sortDirection?: 'ascending' | 'descending';
+ colorScheme?: ColorSchemeRecommendation;
+ interpolation?: 'linear' | 'step' | 'step-after' | 'monotone';
+ binningSuggested?: boolean;
+ stackable?: 'sum' | 'normalize' | false;
+}
+
+type SemanticResult = Record;
+```
+
+## §4.7 辅助类型
+
+```typescript
+interface FormatSpec {
+ pattern?: string; // d3-format, e.g. "$,.0f", ".1%"
+ prefix?: string; // "$", "€"
+ suffix?: string; // "%", "°C", " kg"
+ decimals?: number;
+ abbreviate?: boolean; // 1234567 → "1.2M"
+ temporalPattern?: string; // "%Y", "%b %d"
+}
+
+interface DomainConstraint {
+ min?: number;
+ max?: number;
+ clamp?: boolean; // true = hard clip; false = soft suggestion
+}
+
+interface TickConstraint {
+ integersOnly?: boolean;
+ exactTicks?: number[]; // e.g. Rating 1–5 → [1,2,3,4,5]
+ suggestedCount?: number;
+ minStep?: number;
+}
+```
+
+## §4.8 布局与规范生成
+
+**Stage 3** 仅对 `ChannelSemantics` 和数据操作。关于 stretch 尺寸、overflow 过滤和 facet 网格,请参阅[自动布局算法](/documentation/layout-model)。`declareLayoutMode()` 是模板钩子,通过窄接口让 Stage 4 影响 Stage 3。
+
+**Stage 4** 是后端特定的:(1)通过 `computeZeroDecision()` 与 mark type 最终确定零点;(2)翻译 encodings;(3)运行 `template.instantiate()`;(4)应用布局。模板直接读取扁平 `ChannelSemantics`。
+
+## §4.9 缓存
+
+字段语义可能开销较大,因为包含格式检测和分布分析。按字段缓存,键为 `${fieldName}::${semanticType}::${dataHash}`,其中 `dataHash` 对前约 100 个值做指纹。
+
+---
+
+# §5 解析规则
+
+## §5.1 格式与解析
+
+仅当语义上下文增加价值时才覆盖原生格式化:前缀/后缀、缩写、符号或无逗号年份。通用小数(`Number`、`Score`、`Rating`)使用空 `format: {}`,以便 Vega-Lite 自适应精度。提供格式时,`detectPrecision(values)` 将有意义小数位限制在 0–4。
+
+| Semantic Type | `pattern` | `prefix` | `suffix` | `abbreviate` | Notes |
+|---|---|---|---|---|---|
+| **Count** | `,d` | — | — | — | 带千位分隔的整数 |
+| **Amount** | data-driven | `$` | — | yes | Tooltip `,.2f` |
+| **Price** | `,.2f` | `$` | — | yes | 始终显示分 |
+| **Revenue / Cost** | data-driven | `$` | — | yes | Tooltip `,.2f` |
+| **Percentage** (0–1) | `.Xp%` | — | — | — | 自动检测表示 |
+| **Percentage** (0–100) | data-driven + `d` | — | `%` | — | 无 ×100 |
+| **PercentageChange** | `+.X%` 或 `+.Xf` | — | `%` if 0–100 | — | 始终显示符号 |
+| **Temperature** | data-driven | — | from unit | — | 单位来自注解 |
+| **Score / Rating** | — (empty) | — | — | — | VL 原生轴 |
+| **Rank** | `,d` | — | — | — | 整数 |
+| **Year** | `d` | — | — | — | 无逗号 |
+| **Number** | — (empty) | — | — | — | VL native |
+| **Quantity** | data-driven | — | from unit | yes | 单位来自注解 |
+| **Profit** | `+` + data-driven | `$` | — | yes | 有符号货币 |
+| **Sentiment / Correlation** | `+` + data-driven | — | — | — | 有符号小数 |
+| **Latitude / Longitude** | — (empty) | — | — | — | VL native |
+
+单位/货币优先级为 `annotation.unit` > 列名启发式 > 数据值扫描 > 类型默认。
+
+**解析**是编译器职责,由语义类型引导,而非存储在上下文中:
+
+| Semantic Type | Raw examples | Compiler action |
+|---|---|---|
+| Amount, Price, Revenue | `"$1,234.56"` | 剥离货币和分隔符 |
+| Percentage | `"45.2%"`, `"+12.3%"` | 剥离 `%` 和符号 |
+| Temperature, Quantity | `"23.5°C"`, `"75 kg"` | 剥离单位后缀 |
+| Duration | `"2h 30m"` | 解析为秒 |
+| Timestamp | epoch 或 ISO string | 检测表示 → Date |
+| Boolean | `"Yes"`, `0/1` | 规范化为 boolean |
+| Month | `"January"`, `1` | 规范形式 |
+
+## §5.2 聚合默认值
+
+| Family | Types | Default | Rationale |
+|---|---|---|---|
+| Additive measures | Count, Amount, Revenue, Cost, Quantity, Duration | `sum` | 合计 — 求和自然 |
+| Intensive measures | Percentage, Temperature, Score, Rating, Price, Correlation, Sentiment | `average` | 比率/状态 — 平均自然 |
+| Signed additive | Profit | `sum` | 可为负;求和保留符号 |
+| Discrete numeric | Rank, Index, ID | — | 不可聚合 |
+| Temporal / Categorical | DateTime, Name, Status, … | — | 不可聚合 |
+
+自动聚合在多行映射到相同位置编码时注入正确 aggregate:Revenue → sum,Temperature → mean。错误聚合(如对温度求和)会产生荒谬图表。这是显式编译器选项,因为部分上下文会抑制它:
+
+```typescript
+interface CompilerOptions {
+ /** When true, instantiator injects aggregate transforms for measure fields
+ * when multiple rows share the same positional encoding (e.g. same X in bar/line). */
+ autoAggregate: boolean;
+}
+```
+
+## §5.3 比例尺、域与刻度
+
+**比例尺类型**(构建器中依赖数据):
+
+| Condition | Scale | Example |
+|---|---|---|
+| Measure + >2 orders of magnitude | `log` | Revenue $1K–$1B |
+| Measure + long tail (skew > 2) | `sqrt` | Population |
+| Signed + wide range | `symlog` | Profit −$10M to +$500M |
+| Percentage (0–100) | `linear` | Completion rate |
+| Default quantitative | `linear` | — |
+
+**域约束** — 有效域 = 内在边界与数据范围的并集。软域从不裁剪合法离群值:
+
+| Source | Type | Intrinsic | Data | Effective | Clamp |
+|---|---|---|---|---|---|
+| Annotation | Rating [1,5] | [1,5] | [1,4] | min 1, max 5 | soft |
+| Annotation | Score [0,100] | [0,100] | [0,120] | min 0, max 120 | soft |
+| Data-inferred | Percentage 0–100 | [0,100] | [0,155] | min 0, max 155 | soft |
+| Type-intrinsic | Latitude | [-90,90] | any | [-90,90] | hard |
+| Type-intrinsic | Correlation | [-1,1] | any | [-1,1] | hard |
+
+优先级为 `annotation.intrinsicDomain` > type-intrinsic > data-inferred。小内在跨度(≤20)还会设置 `exactTicks`、`binningSuggested: false`,并细化 `zeroBaseline`。
+
+**刻度约束:**
+
+| Type | `integersOnly` | `exactTicks` | `minStep` | Source |
+|---|---|---|---|---|
+| Count, Year, Rank, Index | true | — | 1 | Type-intrinsic |
+| Rating [1,5] | true | [1,2,3,4,5] | 1 | Annotation |
+| Rating [1,10] | true | [1..10] | 1 | Annotation |
+| Score [0,100] | true | — (span > 20) | 1 | Annotation |
+| Month (1–12) | true | [1..12] | 1 | Type-intrinsic |
+
+## §5.4 坐标轴与标记
+
+| Concern | Rule |
+|---|---|
+| **Reversed axis** | `Rank` → `true`(第 1 名在顶部);其余 → `false`。模板可覆盖。 |
+| **Stacking** | Sum stack:Count、Amount、Revenue、Cost、Quantity、Duration、Profit。Normalize:Percentage。No stack:Temperature、Score、Rating、Rank、Correlation、Sentiment。 |
+| **Interpolation** | Rank/Index → `step`;Temperature、Revenue、Profit → `monotone`;default → `linear`。 |
+| **Binning** | 建议:Quantity、Amount、Temperature、Percentage、Duration、高基数 Count。不建议:Rating (1–5)、Rank、Year、categorical。 |
+
+## §5.5 发散与颜色
+
+发散处理需要**中点**,按以下优先级解析:
+
+1. `annotation.unit` 查找(°C → 0,°F → 32)
+2. 类型内在中点
+3. `intrinsicDomain` 中点(Rating [1,5] → 3)
+4. 数据跨越零 → 中点 0
+5. 数据范围中点(回退)
+
+| Type | Midpoint | Inherent? | Notes |
+|---|---|---|---|
+| Temperature | 0 / 32 / 273.15 by unit | conditional | 全为正时用 sequential |
+| Profit, PercentageChange | 0 | conditional | 单侧时用 sequential |
+| Sentiment, Correlation | 0 | inherent | 中心始终有意义 |
+| Score (0–100) | 50 | conditional | 来自域中点 |
+| Rating (1–5) | 3 | conditional | 很少发散 |
+
+**Inherent** 类型始终使用发散调色板,因为正负值有语义含义。**Conditional** 类型仅当数据跨越中点两侧时使用发散调色板;否则使用 sequential 调色板。
+
+```typescript
+interface ColorSchemeHint {
+ type: 'categorical' | 'sequential' | 'diverging';
+ reversed?: boolean; // Rank: 1 = best = darkest
+ divergingMidpoint?: number;
+ inherentlyDiverging?: boolean;
+}
+
+function resolveColorSchemeHint(semanticType, annotation, values): ColorSchemeHint {
+ const divInfo = resolveDivergingInfo(semanticType, annotation, values);
+ if (divInfo) {
+ const spansBoth = min < divInfo.midpoint && max > divInfo.midpoint;
+ if (divInfo.inherent || spansBoth) {
+ return { type: 'diverging', divergingMidpoint: divInfo.midpoint,
+ inherentlyDiverging: divInfo.inherent };
+ }
+ }
+ return { type: isQuantitative ? 'sequential' : 'categorical' };
+}
+```
+
+---
+
+# §6 示例
+
+## §6.1 收入柱状图
+
+**Input:** `revenue`,`{ semanticType: "Revenue", unit: "EUR" }`,values ~[124500, 89200, …],channel Y,mark bar。
+
+```json
+{
+ "semanticAnnotation": { "semanticType": "Revenue", "unit": "EUR" },
+ "defaultVisType": "quantitative",
+ "format": { "pattern": "€,.0f", "prefix": "€", "abbreviate": true },
+ "tooltipFormat": { "pattern": "€,.2f", "prefix": "€" },
+ "aggregationDefault": "sum",
+ "zeroBaseline": "meaningful",
+ "scaleType": "linear",
+ "binningSuggested": true
+}
+```
+
+通道增量:`nice: true`,`stackable: 'sum'`,`interpolation: 'monotone'`,sequential color。Y 轴显示 €0、€100K、…;包含零点基线;tooltip 显示 €124,500.00。
+
+## §6.2 温度折线图
+
+**Input:** `avg_temp`,`{ semanticType: "Temperature", unit: "°C" }`,values ~[16.8, 31.7, …],channel Y,mark line。
+
+```json
+{
+ "semanticAnnotation": { "semanticType": "Temperature", "unit": "°C" },
+ "defaultVisType": "quantitative",
+ "format": { "pattern": ".1f", "suffix": "°C" },
+ "tooltipFormat": { "pattern": ".2f", "suffix": "°C" },
+ "aggregationDefault": "average",
+ "zeroBaseline": "arbitrary",
+ "binningSuggested": true
+}
+```
+
+通道增量:发散颜色中点 0°C,`interpolation: 'monotone'`,`stackable: false`。轴为数据拟合,不强制 0°C;刻度显示 16°C、20°C、…;线条平滑。
+
+## §6.3 排名 bump 图
+
+**Input:** `rank`,`Rank`,values [1..10],channel Y,mark line (bump)。
+
+```json
+{
+ "semanticAnnotation": { "semanticType": "Rank" },
+ "defaultVisType": "ordinal",
+ "format": { "pattern": "d" },
+ "aggregationDefault": null,
+ "zeroBaseline": "arbitrary",
+ "sortDirection": "ascending",
+ "binningSuggested": false
+}
+```
+
+通道增量:`reversed: true`,`tickConstraint: { integersOnly: true, minStep: 1 }`,`interpolation: 'step'`。Y 轴反转(1 在顶部),刻度为整数,禁用堆叠。
+
+## §6.4 带域的 Rating
+
+**Input:** `rating`,`{ semanticType: "Rating", intrinsicDomain: [1, 5] }`,values [4,3,5,2,4,…],channel Y,mark bar。
+
+```json
+{
+ "semanticAnnotation": { "semanticType": "Rating", "intrinsicDomain": [1, 5] },
+ "defaultVisType": "quantitative",
+ "format": {},
+ "tooltipFormat": { "pattern": ".1f" },
+ "aggregationDefault": "average",
+ "zeroBaseline": "arbitrary",
+ "domainConstraint": { "min": 1, "max": 5, "clamp": false },
+ "binningSuggested": false
+}
+```
+
+通道增量:`nice: false`,`tickConstraint: { integersOnly: true, exactTicks: [1,2,3,4,5] }`。域 [1,5] 来自注解;零点为 arbitrary,因为这是 1-based 比例尺;刻度为精确整数;柱状图从零点使用比例长度,因为 Stage 4 对 bar marks 保留 `scale.zero`。
+
+---
+
+# §7 相关文档
+
+- [Architecture](/documentation/architecture) — 完整编译流水线与仓库布局
+- [API reference](/documentation/api-reference) — `ChartAssemblyInput`、encodings、overflow
+- [Auto Layout Algorithm](/documentation/layout-model) — Stage 3 尺寸、stretch 与 overflow
+
+`chart_spec.encodings` 或 `chartProperties` 中的显式覆盖始终优先于编译器默认值。
diff --git a/docs/zh-CN/design-stretch-model.md b/docs/zh-CN/design-stretch-model.md
new file mode 100644
index 00000000..9d406594
--- /dev/null
+++ b/docs/zh-CN/design-stretch-model.md
@@ -0,0 +1,1096 @@
+# 自动布局算法
+
+当数据超出可用画布时,用于自动调整图表坐标轴尺寸的基于物理的模型。
+
+初次接触 Flint 尺寸?请从[示例:自动布局](/documentation/chart-sizing)开始,然后回到此处阅读完整算法。
+
+**如何阅读本文档:** [§1](#1-layout-mode-classification) 区分 banded 与 continuous 轴并路由到正确模型。[§2](#2-discrete-axis-elastic-budget-model)–[§5](#5-area-layout-2d-pressure-model) 描述四种几何特定模型。[§6](#6-unified-summary) 汇总共享的 pressure–stretch 模式、决策树和实现映射。
+
+| § | Model | Geometry | Chart types |
+|---|---|---|---|
+| [§2](#2-discrete-axis-elastic-budget-model) | Elastic Budget | 1D banded axis | Bar, Histogram, Heatmap, Boxplot |
+| [§3](#3-continuous-axis-gas-pressure-model) | Gas Pressure | 2D point cloud | Scatter, Line, Area |
+| [§4](#4-circumference-radial-pressure-model) | Circumference | 1D closed loop | Pie, Rose, Sunburst, Radar, Gauge |
+| [§5](#5-area-layout-2d-pressure-model) | Area (2D) | 2D filled space | Treemap |
+
+---
+
+## 基准尺寸与 stretch 上限
+
+以下每个模型都从**两个数字**开始:图表瞄准的目标尺寸,以及绝不可超过的上限。
+
+| Field | Role in the model | Default |
+|-------|-------------------|---------|
+| `baseSize` | **目标。** 静息长度 $L_0$ / 基准画布 $W_0 \times H_0$,下方所有「pressure = demand ÷ supply」比率均相对此值衡量。 | $400 \times 320$ |
+| `canvasSize` | **硬上限。** 任意维度(含 facet 网格)的最大 stretch 尺寸。 | none → $\text{baseSize} \times \text{maxStretch}$(默认 $1.5\times$) |
+
+stretch 乘数 $\beta$ 限制轴相对基准可增长的距离,按维度设置:
+
+- **无 `canvasSize`(默认)。** 各维度最多 stretch 到 `maxStretch`(默认 **1.5**),即上限为 $\text{baseSize} \times \text{maxStretch}$。
+- **显式 `canvasSize` 上限。** 上限变为上限与基准之比:
+
+ $$\beta_x = \max\!\left(1, \frac{\text{canvasSize.width}}{\text{baseSize.width}}\right), \qquad \beta_y = \max\!\left(1, \frac{\text{canvasSize.height}}{\text{baseSize.height}}\right).$$
+
+**基准永不超过上限。** 计算上限前,基准按维度钳制到上限($L_0 = \min(\text{baseSize}, \text{canvasSize})$)。因此 `canvasSize` *小于*基准时(例如固定槽位且 `baseSize` 保持默认),$L_0$ 会缩小以适配框体。图表压缩并降级文字,而非溢出。省略 `baseSize` 时,单独 `canvasSize` 表现为纯 **fit-to-box**:$L_0 = \text{canvasSize}$,$\beta = 1$,图表无法超出框体。
+
+驱动宽度的模型(离散 x 轴、x 方向气压模型、矩形树图 x 分割)使用 $\beta_x$;驱动高度的模型(离散 y 轴、y 方向气压模型、矩形树图 y 分割)使用 $\beta_y$;径向和面积模型使用 $\max(\beta_x, \beta_y)$。同一上限也约束**分面**布局:小多图网格的总伸展范围不能超过 `canvasSize`。下文只写 $\beta$ 时,请根据当前维度理解为 $\beta_x$ 或 $\beta_y$。
+
+---
+
+## 交互式演示
+
+这四个模型在数据溢出画布时为图表坐标轴定尺寸。拖动控件可观察各模型在添加项、改变 stretch 因子或调整画布大小时的响应。每个演示内均解释控件;下文各节为需要完整推导的读者提供公式。
+
+### Elastic budget — discrete (banded) axis · [§2](#2-discrete-axis-elastic-budget-model)
+
+Bar、Histogram、Heatmap。随类别增加 plot 变宽,然后压缩,stretch 上限耗尽后溢出。
+
+```flint-playground
+discrete
+```
+
+### Gas pressure — continuous axis · [§3](#3-continuous-axis-gas-pressure-model)
+
+Scatter、Line、Area。轴随点密度温和 stretch,而非按项 snap 到 band。
+
+```flint-playground
+continuous
+```
+
+### Circumference — radial / closed loop · [§4](#4-circumference-radial-pressure-model)
+
+Pie、Rose、Radar。随 slice 拥挤,图表增大半径以保持圆周可读。
+
+```flint-playground
+circumference
+```
+
+### Area — 2D filled space · [§5](#5-area-layout-2d-pressure-model)
+
+Treemap。画布面积增长,使每个矩形保持足够大以便阅读,而非缩成细条。
+
+```flint-playground
+area
+```
+
+---
+
+## 目录
+
+- [§1 布局模式分类](#1-layout-mode-classification)
+ - [§1.1 Banded vs Non-Banded](#11-banded-vs-non-banded)
+ - [§1.2 决策树](#12-decision-tree)
+ - [§1.3 Vega-Lite 实现说明](#13-vega-lite-implementation-notes)
+- [§2 离散轴(Elastic Budget Model)](#2-discrete-axis-elastic-budget-model)
+ - [§2.1 问题](#21-problem)
+ - [§2.2 参数](#22-parameters)
+ - [§2.3 三种状态](#23-three-regimes)
+ - [§2.4 幂律 Elastic Budget](#24-power-law-elastic-budget)
+ - [§2.5 理论基础](#25-theoretical-foundation-spring-model)
+ - [§2.6 分组项](#26-grouped-items)
+ - [§2.7 各 Mark 类型指南](#27-per-mark-type-guidelines)
+ - [§2.8 分面图表](#28-faceted-charts)
+ - [§2.9 摘要](#29-summary)
+- [§3 连续轴(Gas Pressure Model)](#3-continuous-axis-gas-pressure-model)
+ - [§3.1 问题](#31-problem)
+ - [§3.2 参数](#32-parameters)
+ - [§3.3 各轴 Stretch](#33-per-axis-stretch)
+ - [§3.4 Positional ≥ Series 约束](#34-positional--series-constraint)
+ - [§3.5 参数表](#35-parameter-table)
+ - [§3.6 算例](#36-worked-examples)
+ - [§3.7 摘要](#37-summary)
+ - [§3.8 分面连续布局](#38-faceted-continuous-layout-per-subplot-baseline--pressure--ar-blend--fit)
+ - [§3.8.1 问题](#381-problem)
+ - [§3.8.2 各子图基准](#382-per-subplot-baseline-canvas)
+ - [§3.8.3 Banking AR](#383-banking-ar-multi-scale-slope-optimization)
+ - [§3.8.4 Gas–Banking 混合](#384-gasbanking-ar-blend)
+ - [§3.8.5 面积预算](#385-area-budget-and-shape)
+ - [§3.8.6 拟合预算](#386-fit-to-budget-preserving-ar)
+ - [§3.9 Band AR 混合](#39-band-ar-blending)
+- [§4 圆周(Radial Pressure Model)](#4-circumference-radial-pressure-model)
+ - [§4.1 问题](#41-problem)
+ - [§4.2 参数](#42-parameters)
+ - [§4.3 有效项数](#43-effective-item-count)
+ - [§4.4 Pressure 与 Stretch](#44-pressure-and-stretch)
+ - [§4.5 画布尺寸](#45-canvas-sizing)
+ - [§4.6 Gauge 分面](#46-gauge-faceting)
+ - [§4.7 参数表](#47-parameter-table)
+ - [§4.8 摘要](#48-summary)
+- [§5 面积布局(2D Pressure Model)](#5-area-layout-2d-pressure-model)
+ - [§5.1 问题](#51-problem)
+ - [§5.2 参数](#52-parameters)
+ - [§5.3 有效项数](#53-effective-item-count)
+ - [§5.4 Pressure 与偏置分割](#54-pressure-and-biased-split)
+ - [§5.5 算例](#55-worked-examples)
+ - [§5.6 摘要](#56-summary)
+- [§6 统一摘要](#6-unified-summary)
+
+---
+
+# §1 布局模式分类
+
+## §1.1 Banded vs Non-Banded
+
+布局模型需决定**如何**为每个位置轴分配空间。两个独立属性驱动该决策:
+
+1. **Scale type** — 字段的 Vega-Lite 编码类型。
+2. **Mark geometry** — 标记是否占据固定宽度 band 或点状位置。
+
+### Banded layout
+
+**Banded** 轴为每个数据位置分配固定宽度槽位(band)。布局模型控制每槽的步长。项通过 band 的宽度/面积阅读。
+
+| Condition | Example |
+|---|---|
+| **Discrete scale**(nominal / ordinal) | Bar 图上的类别、ordinal 月份 |
+| **Continuous scale + band mark** | X 为 quantitative 或 temporal 的 Bar 图(年份为数字) |
+| **Binned axis**(`bin: true`) | Histogram 分箱 — 无论 scale 如何,每 bin 是一个 band |
+
+### Non-banded layout
+
+**Non-banded** 轴在连续范围内按数据决定的位置放置项。布局模型控制整体画布尺寸,但**不**分配 per-item 槽位。
+
+| Condition | Example |
+|---|---|
+| **Continuous scale + point mark** | Scatter、Line、Area |
+
+### 摘要矩阵
+
+| | Band mark (bar, rect, boxplot) | Point mark (circle, line, area) |
+|---|---|---|
+| **Discrete scale** (N/O) | Banded — §2 | Banded — §2 (*) |
+| **Continuous scale** (Q/T) | Banded — §2 | Non-banded — §3 |
+
+(*) Discrete scale 无论 mark type 均为 banded — VL 为每个类别分配 band。
+
+## §1.2 决策树
+
+```
+For each positional axis (x, y):
+
+1. Is the VL encoding type nominal or ordinal?
+ → YES: Banded (discrete). Use §2 directly.
+
+2. Is the axis binned (enc.bin = true)?
+ → YES: Banded (continuous). Use §2 with bin count as N.
+
+3. Does the template declare this axis as banded?
+ (axisFlags.banded = true, e.g. bar/rect/boxplot marks)
+ → YES: Banded (continuous). Use §2 with field cardinality as N.
+
+4. Otherwise:
+ → Non-banded (continuous). Use §3.
+```
+
+> **Implementation:** 决策在 `compute-layout.ts` 中通过 `axisFlags.x.banded` / `axisFlags.y.banded` 和 `isDiscreteType()` 检查完成。见 `computeLayout()` 约 155–230 行。
+
+## §1.3 Vega-Lite 实现说明
+
+§2 elastic budget 模型适用于 discrete-banded 和 continuous-banded 轴,但 **Vega-Lite 实现因 scale type 而异**:
+
+### Discrete banded (nominal / ordinal)
+
+VL 原生支持基于 step 的尺寸:
+
+```json
+{ "width": { "step": ℓ } }
+```
+
+VL 创建 band scale,为每个类别分配 $\ell$ 像素,并将图表尺寸设为 $N \times \ell$。
+
+分组 bar(xOffset / yOffset):
+
+```json
+{ "width": { "step": ℓ_group, "for": "position" } }
+```
+
+### Continuous banded (quantitative / temporal + band mark)
+
+VL **不**支持 continuous scale 上的 `{ "step": N }`,因此 Flint 分两阶段处理:
+
+**Phase 1 — Canvas sizing (assemble.ts):**
+
+```
+continuousWidth = stepSize × (N + 1)
+```
+
+`+1` 在两侧各加 half-step padding。scale domain 扩展 ±halfStep,使位置对齐如同 discrete band scale。
+
+**Phase 2 — Mark sizing (postProcessing):**
+
+由于 VL 不在 continuous scale 上自动定 bar 尺寸:
+1. 排序唯一字段值;找 `minGap`(最小连续差)。
+2. 转为像素:`pixelsPerUnit = subplotDim × (N−1) / (dataRange × N)`。
+3. `markSize = min(stepSize × 0.9, floor(minGap × pixelsPerUnit))`。
+4. 通过 `{ "mark": { "size": markSize } }` 应用(rect 用 `width`/`height`,fill ratio 0.98)。
+
+### 对比
+
+| Aspect | Discrete banded | Continuous banded |
+|---|---|---|
+| VL scale type | `nominal` / `ordinal` (band scale) | `quantitative` / `temporal` (linear/time scale) |
+| Step control | width/height 上的 `{ "step": ℓ }` | 手动:`config.view.continuousWidth = ℓ × (N+1)` |
+| Mark sizing | 自动(VL 填充 band) | 手动:`mark.size` 来自 min-gap 计算 |
+| Domain padding | 自动(band scale) | 手动:domain 扩展 ±halfStep |
+| Sort control | `encoding.sort` | 数据决定(continuous scale) |
+
+### 何时优先 continuous banded
+
+- 数据具有**自然顺序和算术含义**(年份、日期、价格)。
+- 数据具有**不规则间距** — continuous scale 保留比例位置。
+- 模板声明 `axisFlags.banded = true` 同时保持 VL 编码类型为 Q/T。
+
+`templates/utils.ts` 中的 `detectBandedAxis` 函数处理该决策。
+
+---
+
+# §2 离散轴(Elastic Budget Model)
+
+## §2.1 问题
+
+离散轴沿长度为 $L_0$ 像素的 1D 段显示 $N$ 个 banded 项(类别、分箱、组)。每项理想占据 $\ell_0$ 像素(自然长度)。当 $N \cdot \ell_0 > L_0$ 时项溢出。
+
+需平衡两个竞争目标:
+
+1. **项抵抗压缩** — 每项向外推以维持 $\ell_0$,且不能低于 $\ell_{\min}$。
+2. **轴抵抗扩展** — 轴可超出 $L_0$ stretch,但有硬上限 $L_{\max}$。
+
+## §2.2 参数
+
+| Symbol | Meaning | Code mapping | Default |
+|---|---|---|---|
+| $L_0$ | Natural axis length | `width` / `height` (base size) | 400 px |
+| $L_{\max}$ | Maximum axis length | `base × β`(β 来自 `maxStretch` 或 `canvasSize`) | 800 px |
+| $N$ | Number of banded items | Field cardinality | data-dependent |
+| $\ell_0$ | Natural length per item | `defaultStepSize` | ~20 px |
+| $\ell_{\min}$ | Minimum length per item | `minStep` option | 6 px |
+| $\alpha$ | Elasticity exponent | `elasticity` option | 0.5 |
+| $\beta$ | Maximum stretch multiplier | `maxStretch`,或从 `canvasSize` 推导 | 1.5 |
+
+> **Code defaults:** 未设置 `canvasSize` 上限时,`elasticity: 0.5`、`minStep: 6`、`maxStretch: 1.5`。`defaultStepSize` 根据画布尺寸动态计算:`round(20 × max(1, sizeRatio) × defaultStepMultiplier)`。
+
+## §2.3 三种状态
+
+### Regime 1: No compression needed
+
+**Condition:** $N \cdot \ell_0 \leq L_0$
+
+所有项以自然长度容纳:
+
+$$\ell = \ell_0, \quad L = N \cdot \ell_0$$
+
+### Regime 2: Overflow beyond recovery
+
+**Condition:** $N \cdot \ell_{\min} \geq L_{\max}$
+
+即使最小项长和最大 stretch 仍无法容纳所有项。多余项被截断:
+
+$$N' = \left\lfloor \frac{L_{\max}}{\ell_{\min}} \right\rfloor, \quad \ell = \ell_{\min}, \quad L = L_{\max}$$
+
+### Regime 3: Elastic equilibrium
+
+**Condition:** $N \cdot \ell_0 > L_0$ 且 $N \cdot \ell_{\min} < L_{\max}$
+
+项溢出但可通过压缩项和/或 stretch 轴容纳。此处应用 elastic 模型。
+
+## §2.4 幂律 Elastic Budget
+
+这是**已实现模型**。轴使用 pressure 比率的幂律 stretch:
+
+**Pressure:**
+
+$$p = \frac{N \cdot \ell_0}{L_0}$$
+
+**Stretch factor:**
+
+$$s = \min(\beta,\; p^{\alpha})$$
+
+**Resulting step size:**
+
+$$\ell = \frac{L_0 \cdot s}{N} = \frac{L_0 \cdot p^{\alpha}}{N}$$
+
+$\alpha = 0.5$ 时,溢出加倍仅使 stretch 增加 $\sqrt{2} \approx 1.41\times$ — 自然渐进响应。
+
+**Clamping:** step 钳制到 $[\ell_{\min},\; \ell_0]$,轴长钳制到 $[L_0,\; L_{\max}]$。
+
+> **Implementation:** `core/decisions.ts` 中的 `computeElasticBudget()`(约 549–569 行)。由 `computeAxisStep()` 调用,处理 nominal 和 continuous-as-discrete 情况。
+
+## §2.5 理论基础(spring model)
+
+幂律模型可由物理类比动机:$N$ 个相同弹簧装在盒内。
+
+**Setup:**
+- 每个弹簧(项)自然长度 $\ell_0$,固体长度 $\ell_{\min}$,弹簧常数 $k_1$。
+- 盒子(轴)自然长度 $L_0$,最大长度 $L_{\max}$,弹簧常数 $k_2$。
+
+**Force balance at equilibrium:**
+
+$$N \cdot k_1 \cdot (\ell_0 - \ell) = k_2 \cdot (N \cdot \ell - L_0)$$
+
+**Equilibrium step size**(使用刚度比 $\kappa = k_1 / k_2$):
+
+$$\boxed{\ell = \frac{\kappa \cdot \ell_0 + L_0 / N}{1 + \kappa}}$$
+
+**$\kappa$ 的解释:**
+- $\kappa \to \infty$:项不压缩;墙吸收一切($\ell \to \ell_0$)。
+- $\kappa \to 0$:项压缩以适配固定轴($\ell \to L_0 / N$)。
+- $\kappa = 1$:压缩均分($\ell = (\ell_0 + L_0/N) / 2$)。
+
+线性 spring 模型在物理上更直观,可独立调节项与墙的刚度($\kappa$)。此处作为幂律模型的理论动机呈现。
+
+**Nonlinear (progressive-rate) variant:** 将线性弹簧换为硬化弹簧 $F_1(\ell) = k_1 \cdot ((\ell_0 - \ell) / (\ell_0 - \ell_{\min}))^{\gamma}$ 直接导向实现中的幂律形式。
+
+**与幂律实现的映射:**
+
+| Linear spring model | Power-law implementation |
+|---|---|
+| $\kappa$(刚度比 $k_1/k_2$) | $\alpha$(elasticity exponent) |
+| $\ell = (\kappa \cdot \ell_0 + L_0/N) / (1 + \kappa)$ | $s = \min(\beta, p^{\alpha})$;$\ell = L_0 \cdot s / N$ |
+| 在 $\ell_0$ 与 $L_0/N$ 间均匀插值 | 偏向 $\ell_0$ 的幂曲线插值 |
+| 两参数($k_1$、$k_2$) | 一参数($\alpha$) |
+| 物理上更直观 | 更紧凑;自然渐进 |
+
+## §2.6 分组项
+
+分组项(如每组 $m$ 个子 bar 的分组 bar 图)作为特例:**组**是压缩单位,而非单个项。
+
+| Parameter | Simple discrete | Grouped bar ($m$ sub-bars) |
+|---|---|---|
+| $\ell_0$ (natural) | `defaultStepSize` | $m \times$ `defaultStepSize` |
+| $\ell_{\min}$ (solid) | `minStep` (6 px) | $2m$ px(每子 bar 2 px) |
+| $N$ (item count) | Field cardinality | **组**数量 |
+
+elastic budget 公式不变 — 仅参数值变化。
+
+**Example:** 400 px 轴上 15 组 × 3 子 bar:
+- $N = 15$,$\ell_0 = 60$,ideal $= 900 > 400$ → Regime 3。
+- $\alpha = 0.5$:$p = 900/400 = 2.25$,$s = \min(1.5, 2.25^{0.5}) = 1.50$。
+- Budget $= 400 \times 1.5 = 600$,step $= 600/15 = 40$ px per group。
+
+> **Implementation:** `computeLayout()` 中通过 `group` 通道检测分组。`xHasGrouping` 为 true 时,step 按组计算,`xStepUnit = 'group'`,并强制最小组间距 3 px。
+
+## §2.7 各 Mark 类型指南
+
+不同 mark type 有不同视觉占用和压缩容忍度。模板可通过 `defaultStepMultiplier` 和 `overrideDefaultSettings` 调节行为。
+
+**设计指南**(300 px 参考画布,`defaultStepSize` ≈ 20 px):
+
+| Mark type | $\ell_0$ | $\ell_{\min}$ | Compression tolerance | Rationale |
+|---|---|---|---|---|
+| **Bar** | 20 px | 6 px | Moderate | 宽度编码项,不能缩太多 |
+| **Stacked bar** | 20 px | 6 px | Low | 堆叠段过薄不可读 |
+| **Grouped bar** ($m$) | $20m$ px | $2m$ px | Low | 失去子 bar 区分代价高 |
+| **Lollipop** | 14 px | 4 px | High | 点(位置)承载编码,非宽度 |
+| **Heatmap / rect** | 20 px | 8 px | Very low | 颜色单元需面积才可感知 |
+| **Boxplot** | 24 px | 10 px | Low | 内部结构(box/whiskers/median)早失 |
+| **Strip / jitter** | 24 px | 6 px | Moderate | 过窄时点坍成线 |
+| **Histogram** | 16 px | 4 px | High | 分布形状抗压缩好 |
+| **Candlestick** | 18 px | 8 px | Low | open/close 实体 + 影线需空间 |
+
+**设计原则:**
+1. 以**宽度/面积**编码值的 mark(bar、rect)→ 更高 $\ell_0$,更低压缩容忍。
+2. 以**位置**编码值的 mark(lollipop、bump)→ 更高压缩容忍。
+3. 有**内部结构**的 mark(boxplot、candlestick)→ 更高 $\ell_{\min}$。
+4. 展示**分布形状**的 mark(histogram)→ 可更窄。
+
+> **Note:** 模板目前主要通过 `defaultStepMultiplier`(按比例缩放 $\ell_0$)和 `overrideDefaultSettings` 调节布局。per-mark-type spring 刚度($\kappa$)是设计愿景,代码中尚未单独参数化。
+
+## §2.8 分面图表
+
+分面将一张图拆成子图网格。画布需容纳 $F$ 个面板,各有轴尺寸。本节涵盖**网格布局**:stretch、子图尺寸、换行。分面内 continuous 轴 AR 混合见 [§3.8](#38-faceted-continuous-layout-per-subplot-baseline--pressure--ar-blend--fit)。
+
+### §2.8.1 Facet stretch factor
+
+总画布 stretch 以容纳分面:
+
+$$\lambda_f = \min(\beta,\; F^{\alpha_f})$$
+
+其中 $\alpha_f$ = `facetElasticity`(默认 0.3),$\beta$ = `maxStretch`(无 `canvasSize` 上限时默认 1.5)。
+
+分面 stretch 使用**更温和指数**($\alpha_f = 0.3$ vs discrete 项的 $\alpha = 0.5$),因为每个子图是自包含图表 — 小子图仍可读,而 3 px bar 不行。
+
+> **Implementation:** `computeLayout()` 使用 `facetElasticityVal = 0.3` 和 `resolveStretchCaps()` 的 per-dimension 上限。
+
+### §2.8.2 Subplot sizing
+
+每个子图分得 stretch 画布的一份:
+
+$$W_{\text{sub}} = \max\!\left(S_{\min},\; \frac{W_0 \cdot \lambda_f - \text{fixedPad}}{F_c} - \text{gap}\right)$$
+
+| Symbol | Meaning | Default |
+|---|---|---|
+| $F_c, F_r$ | Facet columns / rows | data-dependent |
+| $\alpha_f$ | Facet elasticity | 0.3 |
+| $S_{\min}$ | Minimum subplot size (continuous axis) | 60 px |
+
+### §2.8.3 Facet-mode shrink limits
+
+分面下轴可比单图模式**进一步**缩小,因为读者比较面板间模式而非精确读单个值。
+
+| Mark type | $\ell_{\min}^{f}$ (banded) | $S_{\min}$ (continuous) |
+|---|---|---|
+| Bar / stacked bar | 3 px | 60 px |
+| Heatmap / rect | 4 px | 40 px |
+| Boxplot | 6 px | 60 px |
+| Line / area | — | 40 px |
+| Ridge / density | — | 20 px |
+| Scatter | — | 60 px |
+
+### §2.8.4 Faceted discrete axis
+
+spring 模型**按子图**运行:$W_{\text{sub}}$ 成为 $L_0$,$N_{\text{items}}$ 为每面板计数。若仍溢出,截断到 $N' = \lfloor W_{\text{sub}} / \ell_{\min} \rfloor$。
+
+### §2.8.5 Faceted continuous axis
+
+gas pressure 模型(§3)在每个子图内运行,容器为 $W_{\text{sub}} \times H_{\text{sub}}$。子图尺寸在各面板间统一以保持视觉一致。
+
+### §2.8.6 Facet wrap (column-only folding)
+
+仅指定 column facet 且 $F$ 超过可容纳最大列数时,面板换行成 2D 网格:
+
+1. **Maximum columns:** $F_{c,\max} = \lfloor \text{effectiveW} / (S_{\min} + \text{gap}) \rfloor$,其中 $\text{effectiveW} = W_0 \times \beta - \text{fixPad}$。
+2. **Single row:** 若 $F \leq F_{c,\max}$,所有面板一行,无需换行。
+3. **Wrapping:** 否则从 $F_c = F_{c,\max}$ 列开始,$F_r = \lceil F / F_c \rceil$ 行。
+4. **Widow avoidance:** 若最后一行恰好 1 个面板("widow"),$F_c$ 减 1 重算。在 $F_c > 2$ 且仍有 widow 时重复。更均匀重分配:11 面板 maxCols=5 会得 5×3 带 1 孤儿,算法改试 4×3,末行 3 面板。
+
+最小子图尺寸($S_{\min}$)按轴感知:
+- **Discrete/banded axes:** $S_{\min} = \ell_{\min} \times N$(minStep × 每轴值计数)。
+- **Continuous axes:** $S_{\min} = \text{baseMinSubplot}$(默认 60 px),两轴均为 continuous 时由 banking AR 调整 — 较短维保持基准,较长维最多 $\beta \times$ 基准。确保 line chart(landscape AR)获得更宽最小子图,产生更少更宽面板。
+
+> **Implementation:** `compute-layout.ts` 中的 `computeFacetGrid()`。在 `computeLayout()` **之前**运行,以打破换行与轴尺寸间的循环依赖。
+
+## §2.9 摘要
+
+| Symbol | Meaning | Default |
+|---|---|---|
+| $N$ | Number of discrete items | data-dependent |
+| $\ell_0$ | Natural step size | ~20 px |
+| $\ell_{\min}$ | Minimum step size | 6 px |
+| $\alpha$ | Elasticity exponent | 0.5 |
+| $\beta$ | Maximum stretch | 1.5 |
+
+```
+Given: N items, natural length ℓ₀, solid length ℓ_min,
+ axis rest length L₀, maxStretch β, elasticity α
+
+pressure = N · ℓ₀ / L₀
+
+if pressure ≤ 1:
+ ℓ = ℓ₀ # Regime 1: fits
+
+elif N · ℓ_min ≥ β · L₀:
+ ℓ = ℓ_min, truncate to N' items # Regime 2: overflow
+
+else:
+ stretch = min(β, pressure^α) # Regime 3: elastic
+ ℓ = L₀ · stretch / N
+ ℓ = clamp(ℓ, ℓ_min, ℓ₀)
+```
+
+> **Key functions:** `core/decisions.ts` 中的 `computeElasticBudget()`、`computeAxisStep()`;`core/compute-layout.ts` 中的 `computeLayout()`。
+
+---
+
+# §3 连续轴(Gas Pressure Model)
+
+## §3.1 问题
+
+连续轴在 2D 画布上显示 $N$ 个点状项(scatter 点或 line 顶点)。与离散项不同,这些 mark 不占据固定 band;它们位于数据决定的位置。每个 mark 有视觉截面积 $\sigma$(px²)。
+
+**为何不用 spring:** 连续 mark 不拥有槽位。100 点与 10 点的 scatter 可共用同一画布;差异是**密度**,非 per-item 分配。因此 pressure 模型比 per-item spring 更合适。
+
+## §3.2 参数
+
+| Symbol | Meaning | Code mapping | Default |
+|---|---|---|---|
+| $W_0, H_0$ | Natural canvas dimensions | `subplotWidth`, `subplotHeight` | 400 × 320 px |
+| $\sigma$ | Mark cross-section (px²) | `markCrossSection` | 30 px² |
+| $\sigma_x, \sigma_y$ | Per-axis cross-sections | `markCrossSectionX/Y` | chart-type specific |
+| $\alpha_c$ | Elasticity exponent | `elasticity` | 0.3 |
+| $\beta_c$ | Maximum stretch | `maxStretch` | 1.5 |
+
+> **Code defaults:** `core/decisions.ts` 中的 `DEFAULT_GAS_PRESSURE_PARAMS` — `markCrossSection: 30`、`elasticity: 0.3`、`maxStretch: 1.5`。
+
+**为何 $\beta_c$ 小于 discrete $\beta$:** 连续轴以**沿比例尺的位置**编码 — 感知上最稳健的通道(Cleveland & McGill, 1984)。scatter 压缩后仍可读,因为相对位置保留。离散轴以 **band 的长度/面积**编码,退化更快。
+
+| | Discrete axis | Continuous axis |
+|---|---|---|
+| Primary encoding | Length / area of band | Position along scale |
+| Recommended $\beta$ | 2.0 | 1.5 |
+
+## §3.3 各轴 Stretch
+
+拥挤几乎总是不对称。Line chart 上 X 由时间点驱动,Y 由重叠 series 驱动。各轴独立 stretch。
+
+### Mode 1: Positional (default)
+
+沿轴统计唯一像素位置(约 1 px 分辨率分桶)。每位置需 $\sigma_{1d} = \sqrt{\sigma}$ 像素:
+
+$$p_{1d} = \frac{\text{uniquePos} \cdot \sigma_{1d}}{\text{dim}_0}$$
+
+$$
+s = \begin{cases}
+1 & \text{if } p_{1d} \leq 1 \cr
+\min(\beta_c,\; p_{1d}^{\,\alpha_c}) & \text{if } p_{1d} > 1
+\end{cases}
+$$
+
+### Mode 2: Series-count (`seriesCountAxis`)
+
+当 `seriesCountAxis` 设为(`'x'`、`'y'` 或 `'auto'`)时,指定轴用 distinct series(color ∪ detail 字段)数量计算 pressure。`'auto'` 解析为:
+- 2D path(两轴 continuous):Y 轴。
+- 1D path(一 continuous + 一 discrete):continuous 轴。
+
+$$p_{\text{series}} = \frac{n_{\text{series}} \cdot \sigma}{\text{dim}_0}$$
+
+此处 $\sigma$ **直接**使用(非开方),因为 series 计数本质 1D。
+
+> **Implementation:** `core/decisions.ts` 中的 `computeGasPressure()`(约 442–508 行)。2D path(两轴 continuous)和 1D path 在 `computeLayout()` 约 275–425 行分别处理。
+
+## §3.4 Positional ≥ Series 约束
+
+两轴均为 continuous 的图表(line、area)中,更多 series 也意味着 **positional** 轴视觉更乱 — 更多重叠线意味着更多交叉和平行笔画争夺读者注意力。positional 轴理想 stretch 至少提升到 series 轴理想 stretch:
+
+$$\text{ideal}_{\text{positional}} = \max(\text{ideal}_{\text{positional}},\; \text{ideal}_{\text{series}})$$
+
+设置 `maintainContinuousAxisRatio` 时,两轴使用两者 stretch 的最大值。
+
+## §3.5 参数表
+
+| Chart type | $\sigma_x$ | $\sigma_y$ | $\alpha_c$ | $\beta_c$ | seriesCountAxis |
+|---|---|---|---|---|---|
+| Scatter | 30 | 30 | 0.3 | 1.5 | — |
+| Line | 100 | 20 | 0.3 | 1.5 | auto (→ Y) |
+| Dotted Line | 100 | 20 | 0.3 | 1.5 | auto (→ Y) |
+| Area | 100 | 20 | 0.3 | 1.5 | auto (→ Y) |
+| Streamgraph | 100 | 20 | 0.3 | 1.5 | auto (→ Y) |
+| Bump | 80 | 20 | 0.3 | 1.5 | auto (→ Y) |
+| Stacked Bar | 20 | 20 | 0.3 | 1.5 | auto (→ Y*) |
+
+\* Stacked bar 的 X 为 discrete(§2),Y 为 continuous。`auto` 经 1D path 解析到 Y。
+
+## §3.6 算例
+
+### Series-axis stretch ($\sigma = 20$, $\text{dim}_0 = 300$, $\alpha_c = 0.3$, $\beta_c = 1.5$)
+
+| Scenario | nSeries | pressure | stretch | Final dim |
+|---|---|---|---|---|
+| 8 series (typical) | 8 | 0.53 | 1.0 | 300 |
+| 15 series (moderate) | 15 | 1.0 | 1.0 | 300 |
+| 20 series (busy) | 20 | 1.33 | 1.09 | 328 |
+| 40 series (extreme) | 40 | 2.67 | 1.35 | 406 |
+
+### Combined positional + series (positional ≥ series constraint)
+
+| Scenario | nDates | nSeries | raw X | raw Y | final X | final Y |
+|---|---|---|---|---|---|---|
+| 12 dates × 20 series | 12 | 20 | 1.0 | 1.09 | **1.09** | 1.09 |
+| 100 dates × 40 series | 100 | 40 | 1.32 | 1.35 | **1.35** | 1.35 |
+| 100 dates × 60 series | 100 | 60 | 1.32 | 1.50 | **1.50** | 1.50 |
+| 200 dates × 3 series | 200 | 3 | 1.50 | 1.0 | 1.50 | 1.0 |
+| 200 dates × 20 series | 200 | 20 | 1.50 | 1.09 | 1.50 | 1.09 |
+
+## §3.7 摘要
+
+| Symbol | Meaning | Default |
+|---|---|---|
+| $\sigma$ | 2D mark cross-section (px²) | 30 |
+| $\sigma_{1d}$ | 1D projection: $\sqrt{\sigma}$ | ~5.5 |
+| $\alpha_c$ | Elasticity exponent | 0.3 |
+| $\beta_c$ | Max stretch | 1.5 |
+
+```
+Given: data points with x/y values, per-axis cross-sections σ_x σ_y,
+ canvas W₀×H₀, elasticity αc, maxStretch βc,
+ optional seriesCountAxis
+
+For each axis (X, Y):
+ if seriesCountAxis resolves to this axis:
+ nSeries = |distinct color ∪ detail values|
+ pressure = nSeries · σ / dim₀
+ else:
+ uniquePos = |{ round(v · px_per_unit) : v ∈ data }|
+ σ_1d = √σ
+ pressure = uniquePos · σ_1d / dim₀
+
+ if pressure ≤ 1:
+ stretch = 1
+ else:
+ stretch = min(βc, pressure^αc)
+
+// Positional ≥ Series constraint (when seriesCountAxis is set):
+stretch_positional = max(stretch_positional, stretch_series)
+
+W = W₀ · stretch_x
+H = H₀ · stretch_y
+```
+
+> **Key functions:** `core/decisions.ts` 中的 `computeGasPressure()`;`core/compute-layout.ts` 中 gas-pressure 集成。
+
+## §3.8 分面连续布局(Per-Subplot Baseline → Pressure → AR Blend → Fit)
+
+### §3.8.1 问题
+
+图表分面时,gas pressure 模型首先要回答:**每个子图的数据相对哪块画布拥挤?**
+
+朴素做法:对全画布运行 gas pressure,再除以列/行数。高估可用空间,因为每个子图只占画布一部分。子图过大,超出总预算。
+
+另一朴素做法:先把原始画布除以列/行数,再 per subplot 运行 gas pressure。低估可用空间,因为忽略布局引擎将应用的 facet stretch。gas pressure 看到人为微小画布并立即饱和。
+
+正确答案是:**gas pressure 在应用 facet elasticity 后的 per-subplot 画布上运行**。与 discrete 轴使用相同 stretch 公式。
+
+### §3.8.2 Per-Subplot Baseline Canvas
+
+gas pressure 运行前,计算仅靠 facet stretch 每个子图会得到什么:
+
+$$W_{\text{sub}} = \max\!\left(S_{\min},\; \frac{W_0 \cdot \lambda_f - \text{fixPad}}{F_c} - \text{gap}\right)$$
+
+其中 $\lambda_f = \min(\beta,\; F_c^{\,\alpha_f})$ 为 facet elasticity stretch(§2.8.1)。单面板图($F_c = 1$)时 $W_{\text{sub}} = W_0$。
+
+这给 gas pressure 现实基准:任何额外 gas-pressure stretch 之前子图将占据的空间。
+
+> **Implementation:** `computeLayout()` 中的 `perSubplotCanvasW/H`(约 410–420 行)。使用 `facetElasticityVal = 0.3` 和 `resolveStretchCaps()` 的 per-dimension 上限。
+
+### §3.8.3 Banking AR (Multi-Scale Slope Optimization)
+
+有连接 mark 的图表(line、area、streamgraph),数据有由线段斜率决定的**感知最优宽高比**。这是 *banking to 45°* 原则(Cleveland, 1993):图表应塑形使中位线段斜率接近 45°,趋势最可见。
+
+我们使用**多尺度 banking**(Heer & Agrawala, 2006),考虑多平滑级别的斜率:
+
+**Algorithm:**
+
+1. **按 series 分组**(color ∪ detail 字段)。每 series 按 X 排序。
+2. **对每个 scale** $k = 0, 1, 2, \ldots$(窗口大小 $= 2^k$):
+ - 用宽度 $2^k$ 的非重叠 box filter 平滑每 series。
+ - 计算连续平滑点间绝对斜率:$|s| = |\Delta y / \Delta x|$(归一化数据坐标)。
+ - 取该 scale 的**中位**绝对斜率。
+3. **合并** per-scale 中位数为几何平均:
+ $$\text{combinedSlope} = \exp\!\left(\frac{1}{K}\sum_{k=0}^{K}\ln(\text{median}_k)\right)$$
+4. **钳制**到 $[0.5,\; 3.0]$。
+5. **Landscape floor**(仅 connected marks):$\text{AR} = \max(1.0,\; \text{combinedSlope})$。时间序列惯例为 landscape;banking 应在斜率陡时推宽,但永不 portrait — 典型时间序列中 Gentle slope 多数会主导中位数并产生 portrait,压缩时间轴。
+
+**Scatter plots**(非 connected):不用线斜率,而用归一化坐标的标准差比 $\sigma_x / \sigma_y$,阻尼响应:$\text{AR} = 1 + 0.3 \times (\text{sdRatio} - 1)$。
+
+**无额外阻尼。** 原始 combined slope 直接返回,无乘性阻尼。与 gas pressure 的 50/50 混合(§3.8.4)是唯一 moderation;此处再加阻尼会 moderation 两次。
+
+> **Implementation:** `compute-layout.ts` 中的 `computeBankingAR()`(约 819 行)。返回 W/H 宽高比于 $[0.5,\; 3.0]$。
+
+### §3.8.4 Gas–Banking AR Blend
+
+Gas pressure 知道哪轴更拥挤(密度不对称)。Banking 知道感知理想 AR(斜率优化)。我们在**对数空间**等权混合两信号:
+
+$$\text{gasAR} = \frac{\text{rawW}}{\text{rawH}} \qquad \text{(from gas pressure per-axis stretches)}$$
+
+$$\text{blendedAR} = \exp\!\left(0.5 \cdot \ln(\text{gasAR}) + 0.5 \cdot \ln(\text{bankingAR})\right)$$
+
+这是几何平均:gas pressure 说 2:1(X 拥挤)且 banking 说 1:1(斜率平缓)时,混合得 $\sqrt{2} \approx 1.41$。
+
+**Coverage gate:** 仅当 X、Y 数据各覆盖至少 20% 各自 domain 时应用 banking。数据集中在小区域(如一角簇)时斜率不可靠,仅 gas pressure 驱动 AR。
+
+### §3.8.5 Area Budget and Shape
+
+混合决定 AR;gas pressure 决定总面积:
+
+$$\text{rawArea} = \text{rawW} \times \text{rawH}$$
+
+封顶以防止子图在 fit 步骤前超出 per-subplot 预算:
+
+$$\text{area} = \min(\text{rawArea},\; W_{\text{sub}} \times H_{\text{sub}} \times \beta)$$
+
+分配面积以匹配 blended AR:
+
+$$\text{idealW} = \sqrt{\text{area} \times \text{blendedAR}} \qquad \text{idealH} = \sqrt{\text{area} / \text{blendedAR}}$$
+
+### §3.8.6 Fit to Budget (Preserving AR)
+
+每子图硬上限:$W_0 \times \beta$ 总量,跨 facet 面板共享:
+
+$$\text{availW} = \frac{W_0 \cdot \beta - \text{fixPad}}{F_c} - \text{gap} \qquad \text{availH} = \frac{H_0 \cdot \beta - \text{fixPad}}{F_r} - \text{gap}$$
+
+均匀缩小以保留 blended AR:
+
+$$\text{fitScale} = \min\!\left(\frac{\text{availW}}{\text{idealW}},\; \frac{\text{availH}}{\text{idealH}},\; 1\right)$$
+
+$$\text{finalW} = \max(S_{\min},\; \text{idealW} \times \text{fitScale}) \qquad \text{finalH} = \max(S_{\min},\; \text{idealH} \times \text{fitScale})$$
+
+均匀 `fitScale` 确保两轴不超预算同时保留 blended AR,最小尺寸极端除外。
+
+### §3.8.7 Worked Example
+
+150 dates × 8 series × 3 column facets,显式 $\beta = 2.0$ 上限便于算术(base $400 \times 300$,line chart:$\sigma_x = 100$,$\sigma_y = 20`,`seriesCountAxis: auto → Y`,`facetElasticity = 0.3`):
+
+**Per-subplot baseline:**
+- Facet stretch: $\lambda_f = \min(2, 3^{0.3}) = 1.35$
+- $W_{\text{sub}} = (400 \times 1.35) / 3 = 180$ px
+
+**Gas pressure**(相对 $180 \times 300$):
+- X positional: 150 unique,$\sigma_{1d} = 10$ → $p = 8.33$ → raw stretch $= 8.33^{0.3} = 1.93$
+- Y series: 8 series,$\sigma = 20$ → $p = 0.53$ → raw stretch $= 1.0$
+- rawW $= 180 \times 1.93 = 347$,rawH $= 300 \times 1.0 = 300$,gasAR $= 1.16$
+
+**Banking AR**(多尺度斜率):设 combinedSlope 得 bankingAR $= 1.8$(landscape)。
+
+**Blend:** $\text{blendedAR} = \exp(0.5 \ln 1.16 + 0.5 \ln 1.8) = \sqrt{1.16 \times 1.8} = 1.44$
+
+**Area:** rawArea $= 347 \times 300 = 104{,}100$。maxArea $= 180 \times 300 \times 2 = 108{,}000$。area $= 104{,}100$。
+- idealW $= \sqrt{104100 \times 1.44} = 387$,idealH $= \sqrt{104100 / 1.44} = 269$
+
+**Fit:** availW $= (800 - 0) / 3 = 267$,availH $= 600$。
+- fitScale $= \min(267/387, 600/269, 1) = 0.69$
+- finalW $= 387 \times 0.69 = 267$,finalH $= 269 \times 0.69 = 186$
+- **Final: 267 × 186, AR = 1.44** ✓ landscape preserved,total width = 800
+
+> **Implementation:** `core/compute-layout.ts` 中的 `computeLayout()` — cont×cont path(约 370–530 行)。`computeBankingAR()`(约 819 行)。`core/decisions.ts` 中的 `computeGasPressure()`。
+
+## §3.9 Band AR Blending
+
+### §3.9.1 问题
+
+一轴 banded、另一轴 continuous 时(如 X 类别、Y 数值的 bar chart),§2 的 step size 决定 band 宽度,continuous 轴用默认画布高度。类别少而画布高时,每个 band 过度拉长。标签拥挤,bar 比例失真,浪费垂直空间。
+
+### §3.9.2 Target Band AR
+
+**Band aspect ratio** 是 continuous 维与 step size 之比:
+
+$$\text{bandAR} = \frac{\text{continuousDim}}{\text{stepSize}}$$
+
+`bandAR` 大(如 20:1)时每个 bar 宽 20 倍于高 — 视觉极端。`targetBandAR` 参数(默认:10)定义最大可接受比。
+
+### §3.9.3 Log-Space Blend
+
+实际 band AR 超过 target 时,continuous 轴通过 50/50 对数空间混合(同 §3.8.4 机制)向 ideal **缩小**:
+
+$$\text{idealDim} = \text{stepSize} \times \text{targetBandAR}$$
+
+$$\text{blendedDim} = \exp\!\left(0.5 \cdot \ln(\text{actualDim}) + 0.5 \cdot \ln(\text{idealDim})\right)$$
+
+结果钳制到 $[S_{\min},\; \text{actualDim}]$ — 混合仅**缩小**,不增大。`bandAR ≤ targetBandAR` 时不调整。
+
+### §3.9.4 Orientation Handling
+
+| Axis layout | Band AR formula | Adjusted dimension |
+|---|---|---|
+| X banded, Y continuous | $H / \text{xStep}$ | Shrink $H$ |
+| Y banded, X continuous | $W / \text{yStep}$ | Shrink $W$ |
+
+### §3.9.5 Worked Example
+
+X 上 5 类别,step = 40 px,canvas height = 300 px,targetBandAR = 10:
+
+- bandAR $= 300 / 40 = 7.5 \leq 10$ → **no adjustment**。
+
+X 上 3 类别,step = 60 px,canvas height = 300 px,targetBandAR = 10:
+
+- bandAR $= 300 / 60 = 5.0 \leq 10$ → **no adjustment**。
+
+X 上 20 类别,step = 12 px,canvas height = 300 px,targetBandAR = 10:
+
+- bandAR $= 300 / 12 = 25 > 10$ → blend。
+- idealH $= 12 \times 10 = 120$。
+- blendedH $= \exp(0.5 \ln 300 + 0.5 \ln 120) = \sqrt{300 \times 120} = 190$ px。
+- **Result: height shrinks from 300 → 190.**
+
+> **Implementation:** `computeLayout()` 中的 Band AR blending 块(约 702–735 行)。由 `options.targetBandAR`(`AssembleOptions`)控制。VL backend 在 `assemble.ts` 中设默认 `targetBandAR = 10`。
+
+---
+
+# §4 圆周(Radial Pressure Model)
+
+## §4.1 问题
+
+Radial 图表(Pie、Rose、Sunburst、Radar)将数据项排列在**圆**周上,相关维度是**圆周**。许多项拥挤圆周时,图表增长以保持 slice 和 spoke 可读。
+
+**为何轴模型不适用:**
+- **§2 (Spring):** 假设有端点的 1D 轴。Radial 图表是闭合环 — 增长意味着增大**半径**,圆周为 $C = 2\pi r$。
+- **§3 (Gas):** 假设 2D 自由浮点。Radial 项角向约束在 slice/spoke 位置。
+
+Circumference 模型将 spring 直觉映射到极坐标:把圆周当作「弯轴」并 stretch 半径。
+
+## §4.2 参数
+
+| Symbol | Meaning | Default |
+|---|---|---|
+| $r_0$ | Base radius: $\max(r_{\min},\; \min(W_0, H_0)/2 - m)$ | derived |
+| $C_0$ | Base circumference: $2\pi r_0$ | derived |
+| $N_{\text{eff}}$ | Effective item count (§4.3) | data-dependent |
+| $\ell_{\text{arc}}$ | Minimum arc-length per item (px) | 45 |
+| $\alpha$ | Elasticity exponent | 0.5 |
+| $\beta$ | Per-dimension max stretch | 2.0 |
+| $r_{\min}$ | Minimum radius | 60 px |
+| $r_{\max}$ | Maximum radius (absolute cap) | 400 px |
+| $m$ | Margin around circle (px) | 20 |
+
+> **Code defaults:** `core/decisions.ts` 中的 `CircumferencePressureParams` — `minArcPx: 45`、`minRadius: 60`、`maxRadius: 400`、`elasticity: 0.5`、`maxStretch: 2.0`、`margin: 20`。
+
+## §4.3 有效项数
+
+不同 radial chart type 拥挤方式不同。模型将这些差异抽象为单一数字 $N_{\text{eff}}$。
+
+**Uniform slices/spokes**(Rose、Radar):$N_{\text{eff}} = N$。
+
+**Variable-width slices**(Pie、Sunburst):
+
+$$N_{\text{eff}} = \frac{\sum v_i}{\min(v_i)}$$
+
+回答:「需要多少个最小 slice 才能填满整圆?」结果上限 100 以防退化。
+
+**Sunburst:** 在**外环**(仅叶节点)计算 $N_{\text{eff}}$ — 最拥挤环。
+
+> **Implementation:** `core/decisions.ts` 中的 `computeEffectiveBarCount()`(约 906–920 行)。
+
+## §4.4 Pressure 与 Stretch
+
+**Pressure:**
+
+$$p = \frac{N_{\text{eff}} \cdot \ell_{\text{arc}}}{C_0} = \frac{N_{\text{eff}} \cdot \ell_{\text{arc}}}{2\pi r_0}$$
+
+**Effective max stretch**(尊重 per-dimension 画布上限):
+
+$$s_{\max} = \min\!\left(\frac{r_{\max}}{r_0},\; \frac{\min(W_0 \cdot \beta,\; H_0 \cdot \beta) - 2m}{2 r_0}\right)$$
+
+**Stretch:**
+
+$$
+s = \begin{cases}
+1 & \text{if } p \leq 1 \cr
+\min(s_{\max},\; p^{\alpha}) & \text{if } p > 1
+\end{cases}
+$$
+
+**Radius:** $r = \text{clamp}(r_0 \cdot s,\; r_{\min},\; r_{\max})$
+
+> **Implementation:** `core/decisions.ts` 中的 `computeCircumferencePressure()`(约 850–893 行)。
+
+## §4.5 画布尺寸
+
+计算最终半径 $r$ 后:
+
+$$W = \max(W_0,\; 2r + 2m), \quad H = \max(H_0,\; 2r + 2m)$$
+
+两画布维度等量增长(保持圆形宽高比)。
+
+## §4.6 Gauge 分面
+
+Gauge 图表是特例:每个 gauge 是单项 radial 图表。多个 gauge 由模板计算 facet 风格网格布局,因为 assembler 的 facet path 不适用于无轴图表。
+
+所有 gauge 元素尺寸随计算半径**连续**缩放:
+
+$$\text{elementSize} = \text{baseline} \times (r / r_{\text{ref}})$$
+
+其中 $r_{\text{ref}} = 100$ px。各元素钳制到最小值。避免阈值伪影。
+
+## §4.7 参数表
+
+| Chart type | $N_{\text{eff}}$ source | $\ell_{\text{arc}}$ | $\alpha$ | $\beta$ | $m$ |
+|---|---|---|---|---|---|
+| **Pie** | `total / min(values)` | 45 | 0.5 | 2.0 | 50 |
+| **Rose** | N categories | 45 | 0.5 | 2.0 | 20 |
+| **Sunburst** | outer-ring `total / min` | 45 | 0.5 | 2.0 | 20 |
+| **Radar** | N spokes | 45 | 0.5 | 2.0 | 20 |
+| **Gauge** | N dials (facet grid) | — | — | 2.0 | 20 |
+
+## §4.8 摘要
+
+```
+Given: N_eff items, minArc ℓ_arc, base canvas W₀×H₀,
+ margin m, elasticity α, maxStretch β, minRadius, maxRadius
+
+r₀ = max(minRadius, (min(W₀, H₀) / 2) - m)
+C₀ = 2π · r₀
+p = N_eff · ℓ_arc / C₀
+
+// Effective max stretch on radius (per-dimension cap)
+s_max = min(maxRadius / r₀,
+ (min(W₀·β, H₀·β) - 2m) / (2·r₀))
+
+if p ≤ 1:
+ r = r₀
+else:
+ r = r₀ · min(s_max, p^α)
+
+r = clamp(r, minRadius, maxRadius)
+W = max(W₀, 2r + 2m)
+H = max(H₀, 2r + 2m)
+```
+
+> **Key functions:** `core/decisions.ts` 中的 `computeCircumferencePressure()`、`computeEffectiveBarCount()`。
+
+---
+
+# §5 面积布局(2D Pressure Model)
+
+## §5.1 问题
+
+面积填充图表(如 Treemap)将 2D 画布划分为面积编码值的矩形。与 Cartesian 图表不同,根本资源是**总面积**。许多项拥挤时,每项过小无法标签或视觉区分。
+
+**为何其他模型不适用:**
+- **§2 / §3:** 独立推理 1D 轴。Treemap 项在任轴上无稳定位置;squarify 算法即时决定划分。
+- **§4:** 推理闭合环。Treemap 项占据 2D 面积,非角向扇区。
+
+## §5.2 参数
+
+| Symbol | Meaning | Default |
+|---|---|---|
+| $W_0, H_0$ | Base canvas dimensions | from context |
+| $A_0$ | Base canvas area: $W_0 \times H_0$ | derived |
+| $N_{\text{eff}}$ | Effective item count (§5.3) | data-dependent |
+| $\ell_{\min}$ | Minimum width per effective item (px) | 30 |
+| $\alpha$ | Elasticity exponent | 0.5 |
+| $\beta$ | Per-dimension max stretch | 2.0 |
+| $b$ | X-bias factor | 1.5 |
+
+> **Implementation note:** 面积模型目前**内联**实现在 `echarts/templates/treemap.ts`(约 91–115 行),非 `decisions.ts` 中的共享 core 函数。公式与默认值与本文完全一致。
+
+## §5.3 有效项数
+
+与 §4.3 相同公式:
+
+$$N_{\text{eff}} = \min\!\left(100,\; \frac{\sum v_i}{\min(v_i)}\right)$$
+
+捕获最坏情况:需要多少最小项副本才能填满整个空间。
+
+> **Implementation:** 调用 `core/decisions.ts` 中的 `computeEffectiveBarCount()`。
+
+## §5.4 Pressure 与偏置分割
+
+### Step 1: 1D Pressure
+
+想象所有 treemap 项沿 X 排成竖条。pressure 相对基准宽度衡量:
+
+$$p = \frac{N_{\text{eff}} \cdot \ell_{\min}}{W_0}$$
+
+### Step 2: Area stretch
+
+$$
+A_{\text{stretch}} = \begin{cases}
+1 & \text{if } p \leq 1 \cr
+\min(\beta^2,\; p^{\alpha}) & \text{if } p > 1
+\end{cases}
+$$
+
+上限为 $\beta^2$,因为 $A = W \times H$ 且各维上限为 $\beta$。
+
+### Step 3: Biased split to X and Y
+
+X 获得更多 stretch,因为阅读多为从左到右,标签为水平。
+
+给定 X-bias factor $b$:
+
+$$s_x = \min(\beta,\; A_{\text{stretch}}^{\,b/(b+1)})$$
+$$s_y = \min(\beta,\; A_{\text{stretch}}^{\,1/(b+1)})$$
+
+**Invariant:** $s_x \times s_y = A_{\text{stretch}}$。
+
+| $b$ | X share | Y share | Effect |
+|---|---|---|---|
+| 1.0 | 50% | 50% | Uniform: $s_x = s_y = \sqrt{A_{\text{stretch}}}$ |
+| 1.5 (default) | 60% | 40% | X takes more |
+| 2.0 | 67% | 33% | Strongly X-biased |
+
+### Step 4: Canvas sizing
+
+$$W = \lfloor W_0 \cdot s_x \rceil, \quad H = \lfloor H_0 \cdot s_y \rceil$$
+
+## §5.5 算例
+
+Base canvas 400×300,$\ell_{\min} = 30$,$\alpha = 0.5$,$\beta = 2.0$,$b = 1.5$:
+
+| Scenario | $N_{\text{eff}}$ | Pressure | $A_{\text{stretch}}$ | $s_x$ | $s_y$ | W | H |
+|---|---|---|---|---|---|---|---|
+| 5 equal items | 5 | 0.38 | 1.0 | 1.0 | 1.0 | 400 | 300 |
+| 10 equal items | 10 | 0.75 | 1.0 | 1.0 | 1.0 | 400 | 300 |
+| 20 equal items | 20 | 1.50 | 1.22 | 1.13 | 1.08 | 452 | 324 |
+| 50 equal items | 50 | 3.75 | 1.94 | 1.52 | 1.27 | 608 | 381 |
+| Skewed (1 large + 20 tiny) | 100 | 7.50 | 2.74 | 1.87 | 1.46 | 748 | 438 |
+
+**为何偏置分割?** Treemap squarify 在画布接近正方形时产生近方形单元。给 X 更多 stretch 优先水平可读性:treemap 单元内标签为水平,额外宽度对容纳文字更有价值。
+
+## §5.6 摘要
+
+| Symbol | Meaning | Default |
+|---|---|---|
+| $N_{\text{eff}}$ | Effective item count ($\sum v / \min v$, cap 100) | data-dependent |
+| $\ell_{\min}$ | Minimum width per effective item (px) | 30 |
+| $\alpha$ | Elasticity exponent | 0.5 |
+| $\beta$ | Per-dimension max stretch | 2.0 |
+| $b$ | X-bias factor (1 = uniform, >1 = X takes more) | 1.5 |
+
+```
+Given: leaf values, base canvas W₀×H₀,
+ minBarPx, elasticity α, maxStretch β, xBias b
+
+N_eff = min(100, sum(values) / min(values))
+p = N_eff · minBarPx / W₀
+
+if p ≤ 1:
+ A_stretch = 1
+else:
+ A_stretch = min(β², p^α)
+
+s_x = min(β, A_stretch^(b/(b+1)))
+s_y = min(β, A_stretch^(1/(b+1)))
+
+W = round(W₀ · s_x)
+H = round(H₀ · s_y)
+```
+
+> **Key function:** 内联于 `echarts/templates/treemap.ts`。使用 `core/decisions.ts` 中的 `computeEffectiveBarCount()`。
+
+---
+
+# §6 统一摘要
+
+四个模型将同一核心思想 **pressure → elastic stretch → clamped output** 适配到不同几何上下文:
+
+| § | Model | Geometry | Pressure formula | Stretch dimension(s) | Chart types |
+|---|---|---|---|---|---|
+| §2 | Elastic Budget | 1D axis | $N \cdot \ell_0 / L_0$ | 1D (axis length) | Bar, Histogram, Heatmap, Boxplot |
+| §3 | Gas Pressure | 2D point cloud | $\text{uniquePos} \cdot \sigma_{1d} / \text{dim}$ | Per-axis (X, Y independent) | Scatter, Line, Area |
+| §4 | Circumference | 1D closed loop | $N_{\text{eff}} \cdot \ell_{\text{arc}} / C_0$ | Radius (both W, H equally) | Pie, Rose, Sunburst, Radar, Gauge |
+| §5 | Area | 2D filled space | $N_{\text{eff}} \cdot \ell_{\min} / W_0$ | Area (biased X/Y split) | Treemap |
+
+### 共享概念
+
+1. **Pressure = demand / supply.** 项需要空间;基准画布提供。Pressure > 1 表示溢出。
+2. **Elastic stretch.** $s = \min(\beta,\; p^\alpha)$。幂律指数 $\alpha$ 控制图表增长激进程度(gas 为 0.3,discrete/radial/area 为 0.5)。
+3. **Per-dimension cap $\beta$.** 无轴增长超过 $\beta \times$ 基准。radial/area 模型转化为半径或面积上限。
+4. **Effective item count.** 可变宽度项(pie、treemap)中,$N_{\text{eff}} = \sum v_i / \min(v_i)$ 衡量最坏拥挤。
+
+### AR-aware extensions (§3.8–§3.9)
+
+连续轴上,原始 pressure 辅以宽高比智能:
+
+5. **Banking AR (§3.8.3).** 多尺度斜率分析(Heer & Agrawala 2006)决定感知理想 W/H 比。Connected marks 有 landscape floor(AR ≥ 1)。Scatter 用 σ-ratio。
+6. **Gas–Banking blend (§3.8.4).** 对数空间 50/50 几何平均:gasAR(密度)× bankingAR(感知)。
+7. **Per-subplot baseline (§3.8.2).** 分面图表向 gas pressure 输入 per-subplot 画布(含 facet elasticity),而非全画布。
+8. **Band AR blending (§3.9).** 一轴 banded、另一 continuous 时,`targetBandAR` 通过对数空间混合防止 band 过度拉长。
+
+### 决策树
+
+```
+Is the chart axis-based?
+├── YES: Does it have banded (discrete) axes?
+│ ├── Both banded → §2 Elastic Budget on each axis
+│ ├── One banded → §2 for banded axis, §3 for continuous axis
+│ │ + §3.9 Band AR blending if targetBandAR set
+│ └── Neither → §3 Gas Pressure (both axes continuous)
+│ + §3.8 Banking AR + Gas–Banking blend
+└── NO: Is the layout radial (items around a circle)?
+ ├── YES → §4 Circumference Model
+ └── NO → §5 Area Model (2D space-filling)
+```
+
+### 实现映射
+
+| Function | File | Model |
+|---|---|---|
+| `computeElasticBudget()` | `core/decisions.ts` | §2 |
+| `computeAxisStep()` | `core/decisions.ts` | §2 |
+| `computeGasPressure()` | `core/decisions.ts` | §3 |
+| `computeBankingAR()` | `core/compute-layout.ts` | §3.8 |
+| `computeCircumferencePressure()` | `core/decisions.ts` | §4 |
+| `computeEffectiveBarCount()` | `core/decisions.ts` | §4, §5 |
+| `computeLayout()` | `core/compute-layout.ts` | §2, §3, §3.8, §3.9 orchestration |
+| `computeFacetGrid()` | `core/compute-layout.ts` | §2.8 faceting, §3.8 min subplot |
+| `computeChannelBudgets()` | `core/compute-layout.ts` | §2.8 overflow budgets |
+| Area pressure (inline) | `echarts/templates/treemap.ts` | §5 |
diff --git a/docs/zh-CN/overview.md b/docs/zh-CN/overview.md
new file mode 100644
index 00000000..f221d4dd
--- /dev/null
+++ b/docs/zh-CN/overview.md
@@ -0,0 +1,182 @@
+# 概览
+
+**Flint** 是一种可视化中间语言。你只需说明每个字段的*含义*和想要的图表;编译器会自动设置比例尺、坐标轴、聚合、格式、布局和颜色,再生成 Vega-Lite、ECharts 或 Chart.js 配置。
+
+如果你是 Flint 新手,请从[入门指南](/documentation/getting-started)开始,然后再回到这里了解架构与 API 地图。
+
+---
+
+## 目录
+
+- [§1 Flint 是什么](#1-what-flint-is)
+- [§2 问题背景](#2-the-problem)
+- [§3 Flint 规范](#3-flint-specification)
+- [§4 编译器输出](#4-compiler-output)
+- [§5 架构一览](#5-architecture-at-a-glance)
+- [§6 文档地图](#6-documentation-map)
+- [§7 安装与快速开始](#7-install-and-quick-start)
+- [§8 本站工具](#8-tools-on-this-site)
+- [§9 延伸阅读](#9-further-reading)
+
+---
+
+# §1 Flint 是什么
+
+Flint 将**数据语义**与**图表意图**分开。你不必手动调整相互依赖的底层参数,AI 智能体也只需生成简洁的 Flint 输入,无需编写冗长且难维护的后端配置。
+
+---
+
+# §2 问题背景
+
+当原始数据类型与视觉映射一致时,声明式语法(Vega-Lite、ECharts 等)效果很好。但当**语义含义**与存储表示不一致时,它们会变得脆弱:
+
+- 整数 `202001` 表示 **YearMonth**,而非定量幅度
+- 堆叠不可加性度量(温度、比率)
+- 在顺序色带上使用发散型字段
+
+专家可以用冗长、耦合的规范修复这些情况,但在更换字段、旋转热力图或更改图表类型时,这些规范很难保持正确。Flint 将**语义类型视为一等对象**,并根据语义与数据特征解析编码和布局。
+
+---
+
+# §3 Flint 规范
+
+一个 Flint 程序有两个可复用部分:
+
+| Flint 术语 | API 字段 | 作用 |
+|------------|-----------|------|
+| **dataSpec** | `semantic_types` | 每个字段的含义 → 类型字符串或富注解 |
+| **chartSpec** | `chart_spec` | 图表类型 + 通道 → 字段绑定 |
+
+原始行数据存放在 `data` 中。三者共同构成 `ChartAssemblyInput`:
+
+```text
+data + semantic_types + chart_spec → assemble*() → 后端配置
+```
+
+### dataSpec 示例
+
+注解**内联**在 `semantic_types` 中——没有单独的 `semantic_annotations` 字段:
+
+```json
+{
+ "semantic_types": {
+ "period": "YearMonth",
+ "game": "Category",
+ "gameType": "Category",
+ "newUsers": "PercentageChange",
+ "totalUsers": "Quantity",
+ "region": {
+ "semanticType": "Category",
+ "sortOrder": ["N", "E", "S", "W"]
+ }
+ }
+}
+```
+
+### chartSpec 示例
+
+按地区拆分的折线图:
+
+```json
+{
+ "chart_spec": {
+ "chartType": "Line Chart",
+ "encodings": {
+ "column": { "field": "region" },
+ "x": { "field": "period" },
+ "y": { "field": "totalUsers" },
+ "color": { "field": "gameType" }
+ },
+ "baseSize": { "width": 480, "height": 320 }
+ }
+}
+```
+
+**探索工作流:** 仅修改 `chart_spec` 即可尝试热力图、分组柱状图、瀑布图或旭日图。**dataSpec 保持不变**,你还可以切换后端(例如 Vega-Lite → ECharts)而无需重写 Flint 输入。模板与后端覆盖范围请见[图库](/gallery)。
+
+语义类型采用三级层次结构。详情见[语义类型](/documentation/semantic-types)。
+
+---
+
+# §4 编译器输出
+
+| 函数 | 输出 |
+|----------|--------|
+| `assembleVegaLite(input)` | Vega-Lite v6 spec |
+| `assembleECharts(input)` | ECharts `option` |
+| `assembleChartjs(input)` | Chart.js config |
+
+同一输入可编译到所有支持的后端。阶段 1–2(语义 + 布局)位于共享的 `core/`;仅阶段 3(模板实例化)因库而异。
+
+完整输入模式见 [API 参考](/documentation/api-reference)。
+
+---
+
+# §5 架构一览
+
+
+
+| Flint 阶段 | 代码 | 模块 |
+|-------------|------|--------|
+| **编译器前端** | Phase 0 — `resolveChannelSemantics()` | `core/resolve-semantics.ts` |
+| **优化器** | Phase 1 — `computeLayout()`、overflow filter | `core/compute-layout.ts` |
+| **代码生成器** | Phase 2 — `template.instantiate()` | `vegalite/`、`echarts/`、`chartjs/` |
+
+1. **前端** — 从 dataSpec + data 推导编码类型、格式、聚合、比例尺、域、颜色和排序
+2. **优化器** — 用基于物理的尺寸选择坐标轴跨度、带宽步长、分面网格和宽高比;先从[示例:自动布局](/documentation/chart-sizing)入手,再用[自动布局算法](/documentation/layout-model)了解公式
+3. **代码生成器** — 根据 `chartType` 选择模板,生成对应后端的配置
+
+流水线详情见[架构](/documentation/architecture)。
+
+---
+
+# §6 文档地图
+
+| 章节 | 页面 |
+|---------|-------|
+| **语言设计** | [架构](/documentation/architecture)、[语义类型](/documentation/semantic-types)、[自动布局算法](/documentation/layout-model)、[API 参考](/documentation/api-reference) |
+| **图表参考** | [Vega-Lite 图表](/documentation/reference-vegalite)、[ECharts 图表](/documentation/reference-echarts)、[Chart.js 图表](/documentation/reference-chartjs) |
+| **开发** | [开发指南](/documentation/development)、[扩展语义类型](/documentation/adding-a-semantic-type)、[扩展后端](/documentation/adding-a-backend)、[扩展图表模板](/documentation/adding-a-chart-template) |
+
+---
+
+# §7 安装与快速开始
+
+```bash
+npm install flint-chart # JavaScript / TypeScript
+npx -y flint-chart-mcp # MCP server for agents
+
+# Python/PyPI is planned for a later release.
+```
+
+```ts
+import { assembleVegaLite } from 'flint-chart';
+
+const spec = assembleVegaLite({
+ data: { values: [{ quarter: 'Q1', revenue: 1200 }] },
+ semantic_types: { quarter: 'Quarter', revenue: 'Price' },
+ chart_spec: {
+ chartType: 'Bar Chart',
+ encodings: { x: { field: 'quarter' }, y: { field: 'revenue' } },
+ baseSize: { width: 480, height: 320 },
+ },
+});
+```
+
+---
+
+# §8 本站工具
+
+| 页面 | 用途 |
+|------|---------|
+| [入门指南](/documentation/getting-started) | 分步创建第一张图表 |
+| [配置 Flint MCP](/documentation/setup-flint-mcp) | MCP 服务器配置、文件访问、工具与验证 |
+| [智能体工作流](/documentation/agent-workflows) | 自定义智能体与产品集成模式 |
+| [图库](/gallery) | 所有模板 + 多后端预览 |
+| [编辑器](/editor) | 粘贴 JSON,切换 Vega-Lite / ECharts / Chart.js |
+
+---
+
+# §9 延伸阅读
+
+- 面向智能体的设计说明:[docs/README.md](https://github.com/microsoft/flint-chart/blob/main/docs/README.md)
diff --git a/docs/zh-CN/reference-chartjs.md b/docs/zh-CN/reference-chartjs.md
new file mode 100644
index 00000000..34945e48
--- /dev/null
+++ b/docs/zh-CN/reference-chartjs.md
@@ -0,0 +1,194 @@
+# Chart.js 图表参考
+
+> 本页由实时 chart-template 注册表(`scripts/gen-chart-reference.ts`)生成。请勿手工编辑 — 请运行 `npm run gen:reference`。
+
+Chart.js 后端是常见图表族的轻量嵌入目标,有意保持较小的参数面。
+
+## 本页内容
+
+本参考列出 Chart.js 后端当前支持的 20 种图表类型,分为 5 个类别。每个图表条目包含:
+
+- **编码通道** — `chart_spec.encodings` 中接受的视觉角色,例如 `x`、`y`、`color`、`size`、`column` 或 `row`。
+- **选项** — 模板专属的 `chart_spec.chartProperties` 键,包括控件类型、域、默认值、可用性与描述。
+
+请严格按照 `chart_spec.chartType` 中显示的名称使用图表类型。
+
+## 如何设置 encodings 与 options
+
+在 `chart_spec.encodings` 中设置 encodings,在 `chart_spec.chartProperties` 中设置图表专属选项。选项键与下方参数名一致:
+
+```jsonc
+{
+ "chartType": "Bar Chart",
+ "encodings": { "x": { "field": "category" }, "y": { "field": "value" } },
+ "chartProperties": { "cornerRadius": 4, "stackMode": "normalize" }
+}
+```
+
+**可用性**列表示参数是 `always`(始终可用)还是 `conditional`(条件可用);后者仅在数据与 encodings 使其相关时出现。例如,对数比例尺控件仅出现在范围较宽的轴上。传入不适用的参数是安全的;组装器会忽略它们。
+
+## 散点与点图
+
+###  Scatter Plot
+
+**编码通道:** `x`, `y`, `color`, `size`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `opacity` | number | 0.1 – 1 (step 0.05) | `1` | always | 标记不透明度。 |
+
+###  Connected Scatter Plot
+
+**编码通道:** `x`, `y`, `order`, `color`, `detail`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Bubble Chart
+
+**编码通道:** `x`, `y`, `size`, `color`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `opacity` | number | 0.1 – 1 (step 0.05) | `0.6` | always | 标记不透明度。 |
+
+###  Strip Plot
+
+**编码通道:** `x`, `y`, `color`, `size`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `stepWidth` | number | 10 – 100 (step 5) | `20` | always | 抖动散布宽度。 |
+| `pointSize` | number | 0 – 150 (step 5) | `0` | always | 点或标记大小。 |
+| `opacity` | number | 0 – 1 (step 0.05) | `0` | always | 标记不透明度。 |
+
+## 条形图
+
+###  Bar Chart
+
+**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `cornerRadius` | number | 0 – 15 (step 1) | `0` | always | 支持的标记的圆角半径。 |
+
+###  Grouped Bar Chart
+
+**编码通道:** `x`, `y`, `group`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge |
+
+###  Stacked Bar Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Combo Chart
+
+**编码通道:** `x`, `y`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `cornerRadius` | number | 0 – 15 (step 1) | `0` | always | 支持的标记的圆角半径。 |
+
+###  Histogram
+
+**编码通道:** `x`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `binCount` | number | 5 – 50 (step 1) | `Auto` | always | 最大分箱上限;Auto 由后端选择。 |
+
+###  Waterfall Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Gantt Chart
+
+**编码通道:** `y`, `x`, `x2`, `color`, `column`, `row`
+
+_无模板专属参数。_
+
+## 折线与面积
+
+###  Line Chart
+
+**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | 折线或面积插值方法。 |
+
+###  Slope Chart
+
+**编码通道:** `x`, `y`, `color`, `detail`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Area Chart
+
+**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)) | — | always | 折线或面积插值方法。 |
+| `opacity` | number | 0.1 – 1 (step 0.05) | `0.4` | always | 标记不透明度。 |
+| `stackMode` | choice | Stacked (default) _(default)_, `layered` (Layered (overlap)) | — | always | 重叠系列的堆叠策略。 |
+
+###  Range Area Chart
+
+**编码通道:** `x`, `y`, `y2`, `color`, `column`, `row`
+
+_无模板专属参数。_
+
+###  ECDF Plot
+
+**编码通道:** `x`, `color`, `detail`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `showPoints` | toggle | on / off | `false` | always | 在线上叠加点标记。 |
+
+## 部分与整体
+
+###  Pie Chart
+
+**编码通道:** `size`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `innerRadius` | number | 0 – 60 (step 5) | `0` | always | 内半径占外半径的百分比。 |
+| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | 排序扇区 |
+
+###  Doughnut Chart
+
+**编码通道:** `size`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `innerRadius` | number | 20 – 80 (step 5) | `55` | always | 内半径占外半径的百分比。 |
+| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | 排序扇区 |
+
+## 极坐标
+
+###  Radar Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `filled` | choice | `true` (Filled (default)), `false` (Outline only) | — | always | 填充雷达图封闭区域。 |
+| `fillOpacity` | number | 0.05 – 0.8 (step 0.05) | `0.3` | always | 面积或区域的填充不透明度。 |
+
+###  Rose Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `alignment` | choice | `left` (Left (default)), `center` (Center) | — | always | 径向图的段对齐方式。 |
+| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | 排序扇区 |
diff --git a/docs/zh-CN/reference-echarts.md b/docs/zh-CN/reference-echarts.md
new file mode 100644
index 00000000..1a6647b7
--- /dev/null
+++ b/docs/zh-CN/reference-echarts.md
@@ -0,0 +1,336 @@
+# ECharts 图表参考
+
+> 本页由实时 chart-template 注册表(`scripts/gen-chart-reference.ts`)生成。请勿手工编辑 — 请运行 `npm run gen:reference`。
+
+ECharts 后端面向交互式、canvas 渲染的图表,并覆盖 Vega-Lite 范围之外的若干结构:sunburst、treemap、sankey、gauge、graph、tree、parallel coordinates 与 calendar heatmap。
+
+## 本页内容
+
+本参考列出 ECharts 后端当前支持的 37 种图表类型,分为 10 个类别。每个图表条目包含:
+
+- **编码通道** — `chart_spec.encodings` 中接受的视觉角色,例如 `x`、`y`、`color`、`size`、`column` 或 `row`。
+- **选项** — 模板专属的 `chart_spec.chartProperties` 键,包括控件类型、域、默认值、可用性与描述。
+
+请严格按照 `chart_spec.chartType` 中显示的名称使用图表类型。
+
+## 如何设置 encodings 与 options
+
+在 `chart_spec.encodings` 中设置 encodings,在 `chart_spec.chartProperties` 中设置图表专属选项。选项键与下方参数名一致:
+
+```jsonc
+{
+ "chartType": "Bar Chart",
+ "encodings": { "x": { "field": "category" }, "y": { "field": "value" } },
+ "chartProperties": { "cornerRadius": 4, "stackMode": "normalize" }
+}
+```
+
+**可用性**列表示参数是 `always`(始终可用)还是 `conditional`(条件可用);后者仅在数据与 encodings 使其相关时出现。例如,对数比例尺控件仅出现在范围较宽的轴上。传入不适用的参数是安全的;组装器会忽略它们。
+
+## 散点与点图
+
+###  Scatter Plot
+
+**编码通道:** `x`, `y`, `color`, `size`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `opacity` | number | 0.1 – 1 (step 0.05) | `1` | always | 标记不透明度。 |
+
+###  Regression
+
+**编码通道:** `x`, `y`, `size`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `regressionMethod` | choice | `linear` (Linear), `log` (Logarithmic), `exp` (Exponential), `pow` (Power), `quad` (Quadratic), `poly` (Polynomial) | `linear` | always | 回归拟合方法。 |
+| `polyOrder` | number | 2 – 10 (step 1) | `3` | always | 回归拟合的多项式阶数。 |
+
+###  Connected Scatter Plot
+
+**编码通道:** `x`, `y`, `order`, `color`, `detail`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Ranged Dot Plot
+
+**编码通道:** `x`, `y`, `color`
+
+_无模板专属参数。_
+
+###  Boxplot
+
+**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `whiskerMethod` | choice | `iqr` (Tukey (1.5 × IQR)), `minmax` (Min–Max) | `iqr` | always | 须线 |
+| `showOutliers` | toggle | on / off | `true` | conditional | 异常值 |
+| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge |
+
+###  Strip Plot
+
+**编码通道:** `x`, `y`, `color`, `size`, `column`, `row`
+
+_无模板专属参数。_
+
+## 条形图
+
+###  Bar Chart
+
+**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `cornerRadius` | number | 0 – 15 (step 1) | `0` | always | 支持的标记的圆角半径。 |
+
+###  Grouped Bar Chart
+
+**编码通道:** `x`, `y`, `group`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge |
+
+###  Stacked Bar Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `layered` (Layered (overlap)) | — | conditional | 重叠系列的堆叠策略。 |
+
+###  Lollipop Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `dotSize` | number | 20 – 300 (step 10) | `80` | always | 圆点标记的大小。 |
+
+###  Pyramid Chart
+
+**编码通道:** `x`, `y`, `color`
+
+_无模板专属参数。_
+
+###  Heatmap
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Calendar Heatmap
+
+**编码通道:** `x`, `color`
+
+_无模板专属参数。_
+
+## 折线与面积
+
+###  Line Chart
+
+**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | 折线或面积插值方法。 |
+| `showPoints` | toggle | on / off | `false` | always | 在线上叠加点标记。 |
+
+###  Bump Chart
+
+**编码通道:** `x`, `y`, `color`, `detail`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Slope Chart
+
+**编码通道:** `x`, `y`, `color`, `detail`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Area Chart
+
+**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | 折线或面积插值方法。 |
+| `opacity` | number | 0.1 – 1 (step 0.05) | `0.7` | always | 标记不透明度。 |
+| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center), `layered` (Layered (overlap)) | — | always | 重叠系列的堆叠策略。 |
+
+###  Streamgraph
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Range Area Chart
+
+**编码通道:** `x`, `y`, `y2`, `color`, `column`, `row`
+
+_无模板专属参数。_
+
+## 部分与整体
+
+###  Pie Chart
+
+**编码通道:** `size`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `innerRadius` | number | 0 – 60 (step 5) | `0` | always | 内半径占外半径的百分比。 |
+| `cornerRadius` | number | 0 – 10 (step 1) | `0` | always | 支持的标记的圆角半径。 |
+| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | 排序扇区 |
+| `labelType` | choice | `categoryPercent` (Name + %), `category` (Name), `value` (Value), `percent` (Percent), `none` (None) | `categoryPercent` | always | 标签 |
+
+###  Funnel Chart
+
+**编码通道:** `y`, `size`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `sort` | choice | `descending` (Descending (default)), `ascending` (Ascending), `none` (Original order) | — | always | 有序阶段或类别的排序顺序。 |
+| `orient` | choice | `vertical` (Vertical (default)), `horizontal` (Horizontal) | — | always | 图表方向。 |
+| `gap` | number | 0 – 20 (step 1) | `2` | always | 段之间的间距。 |
+
+###  Treemap
+
+**编码通道:** `color`, `size`, `detail`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `breadcrumb` | choice | `true` (Show (default)), `false` (Hide) | — | always | 显示或隐藏 treemap 面包屑导航。 |
+
+###  Sunburst Chart
+
+**编码通道:** `color`, `size`, `detail`, `group`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `innerRadius` | number | 0 – 80 (step 5) | `0` | always | 内半径占外半径的百分比。 |
+| `labelRotate` | choice | `radial` (Radial (default)), `tangential` (Tangential), `0` (Horizontal) | — | always | sunburst 扇区的标签方向。 |
+
+###  Tree
+
+**编码通道:** `color`, `detail`, `size`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `orient` | choice | `LR` (Left → Right (default)), `TB` (Top → Bottom) | — | always | 图表方向。 |
+
+## 统计
+
+###  Histogram
+
+**编码通道:** `x`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `binCount` | number | 5 – 50 (step 1) | `Auto` | always | 最大分箱上限;Auto 由后端选择。 |
+
+###  Density Plot
+
+**编码通道:** `x`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `bandwidth` | number | 0.05 – 2 (step 0.05) | `0` | always | 核密度带宽(0 = 自动)。 |
+
+###  ECDF Plot
+
+**编码通道:** `x`, `color`, `detail`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `showPoints` | toggle | on / off | `false` | always | 在线上叠加点标记。 |
+
+###  Parallel Coordinates
+
+**编码通道:** `color`, `detail`
+
+_无模板专属参数。_
+
+## 金融
+
+###  Candlestick Chart
+
+**编码通道:** `x`, `open`, `high`, `low`, `close`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `showMA` | toggle | on / off | `false` | always | 显示移动平均线叠加层。 |
+| `maWindow` | number | 3 – 30 (step 1) | `5` | always | 移动平均线窗口大小。 |
+
+## 其他
+
+###  Waterfall Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Gantt Chart
+
+**编码通道:** `y`, `x`, `x2`, `color`, `detail`, `column`, `row`
+
+_无模板专属参数。_
+
+###  Bullet Chart
+
+**编码通道:** `y`, `x`, `goal`, `color`, `column`, `row`
+
+_无模板专属参数。_
+
+## 极坐标
+
+###  Radar Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `shape` | choice | Polygon (default) _(default)_, `circle` (Circle) | — | always | 网格 |
+| `filled` | choice | `true` (Filled (default)), `false` (Outline only) | — | always | 填充雷达图封闭区域。 |
+| `fillOpacity` | number | 0.05 – 0.8 (step 0.05) | `0.3` | always | 面积或区域的填充不透明度。 |
+
+###  Rose Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `alignment` | choice | `left` (Left (default)), `center` (Center) | — | always | 径向图的段对齐方式。 |
+| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | 排序扇区 |
+
+## 指标
+
+###  Gauge Chart
+
+**编码通道:** `size`, `column`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `min` | number | 0 – 1000 (step 10) | `0` | always | 最小值 |
+| `max` | number | 0 – 10000 (step 100) | `100` | always | 最大值 |
+| `showProgress` | choice | `true` (Show (default)), `false` (Hide) | — | always | 进度 |
+
+## 流向
+
+###  Sankey Diagram
+
+**编码通道:** `x`, `y`, `size`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `orient` | choice | `horizontal` (Horizontal (default)), `vertical` (Vertical) | — | always | 图表方向。 |
+| `nodeWidth` | number | 5 – 40 (step 5) | `20` | always | 节点宽度 |
+| `nodeGap` | number | 2 – 30 (step 2) | `10` | always | 节点间距 |
+
+###  Network Graph
+
+**编码通道:** `x`, `y`, `size`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `layout` | choice | `circular` (Circular (default)), `force` (Force-directed) | — | always | 布局 |
diff --git a/docs/zh-CN/reference-vegalite.md b/docs/zh-CN/reference-vegalite.md
new file mode 100644
index 00000000..1141503b
--- /dev/null
+++ b/docs/zh-CN/reference-vegalite.md
@@ -0,0 +1,428 @@
+# Vega-Lite 图表参考
+
+> 本页由实时 chart-template 注册表(`scripts/gen-chart-reference.ts`)生成。请勿手工编辑 — 请运行 `npm run gen:reference`。
+
+Vega-Lite 后端是 Flint 的参考实现,提供最广的图表覆盖。当你需要最完整的声明式图表支持(包括轴、比例尺与分面行为)时,应使用它。
+
+## 本页内容
+
+本参考列出 Vega-Lite 后端当前支持的 34 种图表类型,分为 6 个类别。每个图表条目包含:
+
+- **编码通道** — `chart_spec.encodings` 中接受的视觉角色,例如 `x`、`y`、`color`、`size`、`column` 或 `row`。
+- **选项** — 模板专属的 `chart_spec.chartProperties` 键,包括控件类型、域、默认值、可用性与描述。
+
+请严格按照 `chart_spec.chartType` 中显示的名称使用图表类型。
+
+## 如何设置 encodings 与 options
+
+在 `chart_spec.encodings` 中设置 encodings,在 `chart_spec.chartProperties` 中设置图表专属选项。选项键与下方参数名一致:
+
+```jsonc
+{
+ "chartType": "Bar Chart",
+ "encodings": { "x": { "field": "category" }, "y": { "field": "value" } },
+ "chartProperties": { "cornerRadius": 4, "stackMode": "normalize" }
+}
+```
+
+**可用性**列表示参数是 `always`(始终可用)还是 `conditional`(条件可用);后者仅在数据与 encodings 使其相关时出现。例如,对数比例尺控件仅出现在范围较宽的轴上。传入不适用的参数是安全的;组装器会忽略它们。
+
+## 点图
+
+###  Scatter Plot
+
+**编码通道:** `x`, `y`, `color`, `size`, `shape`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `opacity` | number | 0.1 – 1 (step 0.1) | `1` | always | 标记不透明度。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Regression
+
+**编码通道:** `x`, `y`, `size`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `regressionMethod` | choice | `linear` (Linear), `log` (Logarithmic), `exp` (Exponential), `pow` (Power), `quad` (Quadratic), `poly` (Polynomial) | `linear` | always | 回归拟合方法。 |
+| `polyOrder` | number | 2 – 10 (step 1) | `3` | always | 回归拟合的多项式阶数。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Connected Scatter Plot
+
+**编码通道:** `x`, `y`, `order`, `color`, `detail`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Ranged Dot Plot
+
+**编码通道:** `x`, `y`, `color`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Strip Plot
+
+**编码通道:** `x`, `y`, `color`, `size`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `stepWidth` | number | 10 – 100 (step 5) | `20` | always | 抖动散布宽度。 |
+| `pointSize` | number | 0 – 150 (step 5) | `0` | always | 点或标记大小。 |
+| `opacity` | number | 0 – 1 (step 0.1) | `0` | always | 标记不透明度。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+## 条形图
+
+###  Bar Chart
+
+**编码通道:** `x`, `y`, `color`, `group`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `cornerRadius` | number | 0 – 15 (step 1) | `0` | always | 支持的标记的圆角半径。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | 将 x 轴解释为连续时间比例尺或离散波段。 |
+| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | 将 y 轴解释为连续时间比例尺或离散波段。 |
+
+###  Grouped Bar Chart
+
+**编码通道:** `x`, `y`, `group`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+###  Stacked Bar Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center), `layered` (Layered (overlap)) | — | conditional | 重叠系列的堆叠策略。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+###  Lollipop Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `dotSize` | number | 20 – 300 (step 10) | `80` | always | 圆点标记的大小。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | 将 x 轴解释为连续时间比例尺或离散波段。 |
+| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | 将 y 轴解释为连续时间比例尺或离散波段。 |
+
+###  Waterfall Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `cornerRadius` | number | 0 – 8 (step 1) | `0` | always | 支持的标记的圆角半径。 |
+| `totals` | choice | `auto` (Auto), `none` (None), `first` (First), `last` (Last), `both` (Both) | `auto` | conditional | 合计 |
+| `showTextLabels` | toggle | on / off | `false` | always | 在标记上渲染数值标签。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+###  Gantt Chart
+
+**编码通道:** `y`, `x`, `x2`, `color`, `detail`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Bullet Chart
+
+**编码通道:** `y`, `x`, `goal`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+## 分布
+
+###  Histogram
+
+**编码通道:** `x`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `binCount` | number | 5 – 50 (step 1) | `Auto` | always | 最大分箱上限;Auto 由后端选择。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+###  Density Plot
+
+**编码通道:** `x`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `bandwidth` | number | 0.05 – 2 (step 0.05) | `0` | always | 核密度带宽(0 = 自动)。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+###  ECDF Plot
+
+**编码通道:** `x`, `color`, `detail`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `showPoints` | toggle | on / off | `false` | always | 在线上叠加点标记。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Violin Plot
+
+**编码通道:** `x`, `y`, `color`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `bandwidth` | number | 0.05 – 2 (step 0.05) | `0` | always | 核密度带宽(0 = 自动)。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+###  Boxplot
+
+**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `whiskerMethod` | choice | `iqr` (Tukey (1.5 × IQR)), `minmax` (Min–Max) | `iqr` | always | 须线 |
+| `showOutliers` | toggle | on / off | `true` | conditional | 异常值 |
+| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Pyramid Chart
+
+**编码通道:** `x`, `y`, `color`
+
+_无模板专属参数。_
+
+###  Candlestick Chart
+
+**编码通道:** `x`, `open`, `high`, `low`, `close`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+## 折线与面积
+
+###  Line Chart
+
+**编码通道:** `x`, `y`, `color`, `strokeDash`, `detail`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | 折线或面积插值方法。 |
+| `showPoints` | toggle | on / off | `false` | always | 在线上叠加点标记。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | 将 x 轴解释为连续时间比例尺或离散波段。 |
+| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | 将 y 轴解释为连续时间比例尺或离散波段。 |
+
+### Sparkline
+
+**编码通道:** `x`, `y`, `color`, `detail`, `row`, `column`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | 折线或面积插值方法。 |
+| `baseline` | choice | `mean` (Average), `zero` (Zero), `median` (Median), `none` (None) | `mean` | always | 参考线 |
+| `trendWidth` | number | 80 – 600 (step 10) | `240` | always | 迷你图宽度 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Bump Chart
+
+**编码通道:** `x`, `y`, `color`, `detail`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Slope Chart
+
+**编码通道:** `x`, `y`, `color`, `detail`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Area Chart
+
+**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | 折线或面积插值方法。 |
+| `opacity` | number | 0.1 – 1 (step 0.1) | `0.7` | always | 标记不透明度。 |
+| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center), `layered` (Layered (overlap)) | — | conditional | 重叠系列的堆叠策略。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | 将 x 轴解释为连续时间比例尺或离散波段。 |
+| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | 将 y 轴解释为连续时间比例尺或离散波段。 |
+
+###  Streamgraph
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | 折线或面积插值方法。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+###  Range Area Chart
+
+**编码通道:** `x`, `y`, `y2`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)) | — | always | 折线或面积插值方法。 |
+| `opacity` | number | 0.1 – 1 (step 0.1) | `0.5` | always | 标记不透明度。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+## 圆形图
+
+###  Pie Chart
+
+**编码通道:** `size`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `innerRadius` | number | 0 – 100 (step 5) | `0` | always | 内半径占外半径的百分比。 |
+| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | 排序扇区 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+###  Rose Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `padAngle` | number | 0 – 0.1 (step 0.005) | `0` | always | 径向段之间的角度间距。 |
+| `alignment` | choice | `left` (Left (default)), `center` (Center) | — | always | 径向图的段对齐方式。 |
+| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | 排序扇区 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+###  Radar Chart
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `filled` | toggle | on / off | `true` | always | 填充雷达图封闭区域。 |
+| `fillOpacity` | number | 0 – 0.5 (step 0.1) | `0.15` | always | 面积或区域的填充不透明度。 |
+| `strokeWidth` | number | 0.5 – 4 (step 0.5) | `1.5` | always | 线条描边宽度。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+## 表格与地图
+
+###  Heatmap
+
+**编码通道:** `x`, `y`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `showTextLabels` | toggle | on / off | `false` | always | 在标记上渲染数值标签。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+| `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | 将 x 轴解释为连续时间比例尺或离散波段。 |
+| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | 将 y 轴解释为连续时间比例尺或离散波段。 |
+
+###  Bar Table
+
+**编码通道:** `y`, `x`, `color`, `column`, `row`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `maxRows` | number | 5 – 100 (step 1) | `20` | always | 显示的最大表格行数。 |
+| `showPercent` | toggle | on / off | `false` | conditional | 将每个值显示为占总数的百分比。 |
+| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 |
+
+###  KPI Card
+
+**编码通道:** `metric`, `value`, `goal`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `layout` | choice | `horizontal` (Horizontal), `vertical` (Vertical), `grid` (Grid) | `grid` | always | 布局 |
+| `style` | toggle | on / off | `true` | always | 卡片样式 |
+| `behindThreshold` | number | 0 – 1 (step 0.05) | `0.5` | conditional | 落后阈值 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Map
+
+**编码通道:** `longitude`, `latitude`, `color`, `size`, `opacity`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `region` | choice | `auto` (Auto-detect), `us` (United States), `world` (World) | `auto` | always | 区域 |
+| `projection` | choice | `default` (Default), `mercator` (Mercator), `equalEarth` (Equal Earth), `orthographic` (Orthographic (Globe)), `stereographic` (Stereographic), `conicEqualArea` (Conic Equal Area), `conicEquidistant` (Conic Equidistant), `azimuthalEquidistant` (Azimuthal Equidistant), `mollweide` (Mollweide) | `default` | conditional | 投影 |
+| `projectionCenter` | choice | Default _(default)_, `0,0` (World (Atlantic) [0, 0]), `150,0` (World (Pacific) [150, 0]), `105,35` (China [105, 35]), `-98,39` (USA [-98, 39]), `10,50` (Europe [10, 50]), `138,36` (Japan [138, 36]), `78,22` (India [78, 22]), `-52,-14` (Brazil [-52, -14]), `134,-25` (Australia [134, -25]), `100,60` (Russia [100, 60]), `20,0` (Africa [20, 0]), `45,28` (Middle East [45, 28]), `115,5` (Southeast Asia [115, 5]), `-60,-15` (South America [-60, -15]), `-100,45` (North America [-100, 45]), `-2,54` (UK [-2, 54]), `10,51` (Germany [10, 51]), `2,47` (France [2, 47]), `128,36` (Korea [128, 36]) | — | conditional | 中心 |
+| `logScale_x` | toggle | on / off | `false` | conditional | 在 x 轴上使用对数/symlog 比例尺。 |
+| `logScale_y` | toggle | on / off | `false` | conditional | 在 y 轴上使用对数/symlog 比例尺。 |
+| `includeZero_x` | toggle | on / off | `false` | conditional | 将 x 轴锚定在零。 |
+| `includeZero_y` | toggle | on / off | `false` | conditional | 将 y 轴锚定在零。 |
+
+###  Choropleth
+
+**编码通道:** `id`, `color`, `detail`
+
+| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 |
+|---|---|---|---|---|---|
+| `region` | choice | `auto` (Auto-detect), `us` (United States), `world` (World) | `auto` | always | 区域 |
diff --git a/docs/zh-CN/tutorials/agent-workflows.md b/docs/zh-CN/tutorials/agent-workflows.md
new file mode 100644
index 00000000..5f071ef5
--- /dev/null
+++ b/docs/zh-CN/tutorials/agent-workflows.md
@@ -0,0 +1,326 @@
+# 智能体工作流
+
+本指南介绍一种 Data Formulator 风格的集成方式:智能体协助创建图表,应用负责数据处理、校验、状态管理、界面控件、编译和渲染。
+
+若你只想在聊天或 IDE 客户端中将 Flint 作为 MCP 工具连接,请从[配置 Flint MCP](/documentation/setup-flint-mcp) 开始。本页聚焦库式集成:即 [Data Formulator](https://github.com/microsoft/data-formulator) 等产品所用的模式——智能体可提议数据塑形与图表请求,而产品控制实际运行与存储内容。
+
+## 核心思想
+
+不要让智能体直接输出 Vega-Lite、ECharts、Chart.js 或渲染器代码。让它生成 Flint `ChartAssemblyInput`:
+
+```jsonc
+{
+ "data": { "values": [/* rows, or bound by the host */] },
+ "semantic_types": {
+ "month": "YearMonth",
+ "product": "Category",
+ "revenue": "Quantity"
+ },
+ "chart_spec": {
+ "chartType": "Line Chart",
+ "encodings": {
+ "x": { "field": "month" },
+ "y": { "field": "revenue" },
+ "color": { "field": "product" }
+ }
+ }
+}
+```
+
+这样得到的图表请求简洁、可检查、可编辑。Flint 编译器负责轴类型、零基线、时间解析、数字格式、默认颜色、尺寸、布局和后端图形细节。
+
+将 Flint 视为智能体与渲染器之间的语义图表层:
+
+```text
+user intent + data context
+ ↓
+agent proposes data preparation + ChartAssemblyInput
+ ↓
+host validates fields, schema, policy, and backend support
+ ↓
+Flint compiles to Vega-Lite / ECharts / Chart.js
+ ↓
+product renders, stores, edits, or asks the agent for a revision
+```
+
+## 产品职责
+
+在自定义工作流中,智能体不应拥有整个可视化系统。为每一部分明确分工:
+
+| 层级 | 职责 |
+|------|------|
+| Agent | 理解用户请求、检查数据上下文、提议转换、选择语义类型,并起草或修订 `chart_spec`。 |
+| Host product | 执行数据转换、绑定行、验证字段、执行策略、存储图表状态、暴露 UI 控件并选择后端。 |
+| Flint | 将语义图表请求编译为带确定性设计默认值的后端原生图表规范。 |
+| Renderer | 在浏览器、notebook、服务或导出流水线中绘制后端规范。 |
+
+这种分工让工作流更稳定。智能体处理图表意图,应用控制执行、状态、安全和用户体验,Flint 负责不应堆在提示词里的可视化规则。
+
+## 将 Flint 输入存为图表状态
+
+尽可能存储 Flint 输入,而非生成的后端 JSON。
+
+- **DataSpec** 是 `data` 与 `semantic_types` 部分。它告诉 Flint 有哪些列及其含义。
+- **ChartSpec** 是 `chart_spec` 部分。它告诉 Flint 使用哪种图表模板以及字段如何映射到通道。
+
+Flint 输入通常很小,可以存入产品数据库、notebook 单元、仪表盘配置或对话状态。界面可以直接修改通道、图表类型、排序、尺寸和图表属性,无需让智能体重新生成底层 JSON。
+
+按需编译后端规范:
+
+```ts
+import { assembleChartjs, assembleECharts, assembleVegaLite } from 'flint-chart';
+
+const vegaLiteSpec = assembleVegaLite(input);
+const echartsOption = assembleECharts(input);
+const chartjsConfig = assembleChartjs(input);
+```
+
+只安装产品需要的渲染器依赖:
+
+```bash
+npm install flint-chart
+npm install vega vega-lite vega-embed # browser Vega-Lite rendering
+npm install echarts # ECharts rendering
+npm install chart.js # Chart.js rendering
+```
+
+Python 支持将使用相同的输入结构,但计划在后续版本发布,不属于首次公开发版。目前请在已发布工作流中使用 npm 包或 MCP 服务器。
+
+## 为智能体提供图表编写指南
+
+图表编写指南位于
+[agent-skills/flint-chart-author/SKILL.md](https://github.com/microsoft/flint-chart/blob/main/agent-skills/flint-chart-author/SKILL.md)。
+可以将它放入智能体提示词、工具描述或检索上下文。
+
+即使智能体没有安装 Flint,也可以通过这份指南了解准确的图表类型名称、支持的通道、图表属性、语义类型和数据绑定规则。
+
+给智能体的持久指令是:
+
+- 为字段选择语义类型,例如 `YearMonth`、`Quantity`、`Category`、`Price`、`Profit` 或 `Percentage`;
+- 按确切名称选择受支持的 `chartType`;
+- 将真实字段绑定到受支持通道,如 `x`、`y`、`color`、`row`、`column`、`size` 或 `group`;
+- 当请求的视图需要聚合、过滤、连接、透视、派生列或宽/长重塑时,在 Flint 之前转换数据;
+- 返回 Flint 输入,而非后端原生 JSON,除非用户明确要求 post-Flint 后端定制。
+
+## 演练:Data Formulator 风格创作
+
+想象一个 Data Formulator 风格产品,拥有原始 `orders` 表。用户问:
+
+```text
+Show monthly revenue by region.
+```
+
+原始表包含如下列:
+
+| Field | Example | 含义 |
+|-------|---------|------|
+| `order_date` | `2025-01-17` | 每笔订单的日期 |
+| `region` | `West` | 销售地区 |
+| `segment` | `Consumer` | 客户细分 |
+| `sales` | `1240.50` | 订单收入 |
+| `profit` | `310.20` | 订单的有符号利润 |
+
+用户并未要求对单笔订单作图。他们要求月度聚合,因此产品不应让智能体把聚合藏进 Vega-Lite transform。产品应要求两件事:
+
+1. 创建可直接作图表格的代码或声明式计划;
+2. 针对该派生表的 Flint `ChartAssemblyInput`。
+
+### 1. 向智能体发送紧凑上下文
+
+应用向智能体发送用户请求和数据概要,通常无需发送整个数据集。
+
+```text
+Use the Flint chart authoring skill.
+
+User request: Show monthly revenue by region.
+
+Current table: orders
+Fields:
+- order_date: Date, examples 2025-01-17, 2025-01-22
+- region: Category, examples West, East, Central, South
+- segment: Category, examples Consumer, Corporate
+- sales: Quantity, numeric, non-negative
+- profit: Profit, numeric, signed
+
+Return one JSON object with:
+- transform_code: Python pandas code that creates a chart-ready DataFrame named chart_df
+- chart_input: a Flint ChartAssemblyInput for chart_df
+
+Do not write Vega-Lite, ECharts, Chart.js, or renderer code.
+Use only fields produced by chart_df in chart_input.
+Leave chart_input.data.values empty; the host will bind rows after executing the transform.
+```
+
+### 2. 让智能体提议数据塑形与 Flint 输入
+
+好的响应将数据转换与图表请求分开:
+
+```jsonc
+{
+ "transform_code": "import pandas as pd\nchart_df = orders.copy()\nchart_df['month'] = pd.to_datetime(chart_df['order_date']).dt.to_period('M').astype(str)\nchart_df = chart_df.groupby(['month', 'region'], as_index=False).agg(revenue=('sales', 'sum'))",
+ "chart_input": {
+ "data": { "values": [] },
+ "semantic_types": {
+ "month": "YearMonth",
+ "region": "Region",
+ "revenue": "Amount"
+ },
+ "chart_spec": {
+ "chartType": "Line Chart",
+ "encodings": {
+ "x": { "field": "month" },
+ "y": { "field": "revenue" },
+ "color": { "field": "region" }
+ }
+ }
+ }
+}
+```
+
+重要属性是拆分:智能体不会把聚合塞进后端 JSON,Flint 输入只引用派生表中会存在的字段。
+
+### 3. 在应用中执行并检查
+
+产品在自有可信或沙箱计算路径中执行 `transform_code`,然后在渲染前检查 `chart_df`。例如:
+
+```text
+month region revenue
+2025-01 East 18420.50
+2025-01 West 21310.10
+2025-02 East 19770.00
+2025-02 West 22640.75
+```
+
+如果代码使用未知列、产生过多行、出现意外空值或未通过安全检查,应用可以拒绝执行。智能体提出方案,应用决定是否运行和保留结果。
+
+### 4. 绑定行并用 Flint 编译
+
+执行后,应用将 `chart_df` 的数据写入 `chart_input.data.values`,再编译图表。
+
+```ts
+import { assembleVegaLite } from 'flint-chart';
+
+const chartInput = {
+ ...agentResult.chart_input,
+ data: { values: chartRows },
+};
+
+const vegaLiteSpec = assembleVegaLite(chartInput);
+```
+
+同一份存储的 Flint 输入稍后也可编译到其他后端:
+
+```ts
+import { assembleECharts } from 'flint-chart';
+
+const echartsOption = assembleECharts(chartInput);
+```
+
+### 5. 围绕 Flint 状态构建 UI
+
+在 Data Formulator 风格的界面中,产品可以同时展示两项内容:
+
+- 派生数据视图(`chart_df`),供用户检查正在作图的表格;
+- ChartSpec 控件,使用户可更改图表类型、通道、排序、尺寸或图表属性,而无需编辑后端 JSON。
+
+如果用户说“也按 segment 拆分”,应用可以让智能体修改规范,也可以提供界面控件,将 `segment` 加入 `column`、`row` 或 `color`。无论采用哪种方式,都只需修改 Flint 输入并重新编译。
+
+### 6. 将后端微调放在下游
+
+如果产品需要某个后端特有的标注或交互,请在 Flint 编译之后添加。Flint 输入仍是图表的主要状态;修改后的 Vega-Lite 或 ECharts JSON 只用于最终渲染。
+
+## 可复用的提示词
+
+集成到产品时,提示词应比自由对话更严格,并要求智能体返回可验证的结构化数据。如果当前数据已经可以直接作图,只需让智能体返回 Flint 输入:
+
+```text
+Use the Flint chart authoring skill.
+
+Return only a valid ChartAssemblyInput JSON object.
+The current data view has fields: month, product, revenue, profit.
+Create a monthly revenue trend by product.
+Infer semantic_types, use only fields that exist, and do not write Vega-Lite,
+ECharts, Chart.js, or renderer code.
+```
+
+当数据尚未可直接作图时,要求转换代码加 Flint 输入:
+
+```text
+Use the Flint chart authoring skill.
+
+From the orders table, create monthly revenue by region.
+Return one JSON object with:
+- transform_code: Python pandas code that creates a chart-ready DataFrame named chart_df
+- chart_input: a Flint ChartAssemblyInput for the transformed rows
+
+The chart_input.data field should be empty or placeholder rows. The host will
+execute the code, inspect the derived table, and bind data.values afterward.
+```
+
+系统随后可解析 JSON、验证、执行或拒绝转换、存储接受的 Flint 输入、编译、渲染预览,或将具体反馈发回智能体。
+
+## 渲染或保存前验证
+
+校验应由应用执行,不能只依赖提示词。接受智能体生成的图表前,请检查:
+
+- 每个编码字段存在于当前或派生数据视图中;
+- `semantic_types` 使用 Flint 已注册标签,而非自造名称;
+- `chartType` 对所选后端与产品界面允许;
+- 编码通道被该图表类型支持;
+- 必需通道存在;
+- 本地策略允许请求的后端、数据大小、图表大小与文件访问模式;
+- 派生数据塑形发生在 Flint 上游,而非通过自造的 Flint transform 属性。
+
+如果产品使用 MCP 服务器,可以调用 `validate_chart` 在服务端校验。如果产品直接集成 Flint 库,请在 `try/catch` 中调用对应的编译函数,并在界面中显示警告或错误。
+
+## 构建编辑循环
+
+首张图表渲染后,修订保持语义化。请智能体或 UI 更改 Flint 输入,而非后端规范:
+
+| 用户意图 | 产品操作 |
+|----------|----------|
+| "Compare regions side by side" | 更改图表类型,或将 region 字段路由到 `group`、`color`、`column` 或 `row`,取决于图表族。 |
+| "Show profit instead of revenue" | 在 `chart_spec.encodings` 中替换度量字段。 |
+| "Use a small multiple view" | 当图表支持分面时,将分组字段移到 `column` 或 `row`。 |
+| "Make it a donut" | 保持 `Pie Chart` 并设置 `chartProperties.innerRadius`。 |
+| "Try ECharts" | 用 `assembleECharts` 重新编译同一份 Flint 输入。 |
+
+这使用户编辑廉价且确定。智能体对模糊意图、数据转换或图表设计建议有用。常规 UI 编辑可直接修改 ChartSpec。
+
+## 仅在 Flint 之后做后端定制
+
+部分产品需求是后端特定的:标注、精确轴样式、自定义 mark 装饰或渲染器特定交互。将这些更改放在 Flint 编译步骤之后。
+
+推荐路径:
+
+1. 将 Flint 输入存为规范图表状态。
+2. 编译到目标后端。
+3. 应用最小的后端特定展示 patch。
+4. 渲染 patch 后的后端规范。
+
+不要将 patch 后的 Vega-Lite、ECharts 或 Chart.js JSON 回灌 Flint。一旦编辑后端 JSON,它就不再是可移植的 Flint 状态。
+
+## 一套实用流程
+
+典型的自定义智能体实现如下:
+
+1. 收集用户请求与紧凑数据概要:列名、样本行、语义提示、基数、最小/最大值与已知单位。
+2. 请智能体返回 `ChartAssemblyInput`,或转换代码加 `ChartAssemblyInput`。
+3. 在产品拥有的沙箱或可信计算路径中执行转换。
+4. 从当前或派生表绑定 `data.values`。
+5. 验证字段、语义类型、图表类型、通道、后端支持、尺寸与策略。
+6. 将 Flint 输入存为规范图表状态。
+7. 编译到所选后端并渲染。
+8. 让 UI 控件或智能体修订 Flint 输入,然后从验证重复。
+
+该循环使 Flint 成为自然语言图表意图与生产渲染之间的稳定契约。
+
+## 下一步
+
+- [入门指南](/documentation/getting-started) 用一张小图解释 DataSpec 与 ChartSpec 的结构。
+- [示例:数据故事](/documentation/data-story) 展示同一份源数据视图如何仅通过更改 ChartSpec 变成多种图表设计。
+- [配置 Flint MCP](/documentation/setup-flint-mcp) 说明如何通过 MCP 服务器为智能体提供 Flint 图表工具。
+- [Vega-Lite charts](/documentation/reference-vegalite)、
+ [ECharts charts](/documentation/reference-echarts) 与
+ [Chart.js charts](/documentation/reference-chartjs) 按后端列出支持的图表类型。
+- [语义类型](/documentation/semantic-types) 列出智能体可用于字段的标签。
diff --git a/docs/zh-CN/tutorials/chart-sizing.md b/docs/zh-CN/tutorials/chart-sizing.md
new file mode 100644
index 00000000..b3405f68
--- /dev/null
+++ b/docs/zh-CN/tutorials/chart-sizing.md
@@ -0,0 +1,105 @@
+# 示例:自动布局
+
+Flint 的自动布局只有一个目标:让图表适应数据和容器,同时保持图形元素清晰可读。数据变密时,算法会结合压力模型和倾斜优化原则(banking),调整画布尺寸、元素间距、宽高比和多面板布局。
+
+主要权衡是在图表内部压缩元素,与将图表拉伸超出 `baseSize` 之间的取舍。拉伸受 `canvasSize` 或默认增长上限约束,因此图表在需要时获得更多空间,但不会无限增长。
+
+下面四个示例分别展示一种布局压力:
+[带状轴](#带状轴) 用于类别与分箱,
+[连续轴](#连续轴) 用于密集点、时间序列与多条线,
+[径向图表](#径向图表) 用于扇区与辐条,以及
+[面积布局](#面积布局) 用于紧凑的二维空间。
+这些示例使用默认的增长上限,便于观察数据密度、`baseSize` 和 `elasticity` 如何影响布局。
+
+## 带状轴
+
+Bar、histogram、heatmap 与 boxplot 为每个项目分配一个槽位。在下方添加类别,观察图表变宽直至拉伸上限用尽。
+
+```flint-playground
+discrete
+```
+
+## 连续轴
+
+Scatter、line 与 area 图表不为每行分配一个 band,但密集点与多系列仍会产生压力。在下方添加点或系列,观察轴更渐进地拉伸。
+
+```flint-playground
+continuous
+```
+
+## 径向图表
+
+Pie、rose、radar 及类似闭环图表需要足够的周长容纳每个扇区或辐条。在下方添加扇区,观察当请求的画布过紧时图表如何增大半径。
+
+```flint-playground
+circumference
+```
+
+## 面积布局
+
+Treemap 与其他填充式二维布局需要足够总面积使矩形保持可读。在下方添加叶子节点,观察画布按面积而非单轴扩展。
+
+```flint-playground
+area
+```
+
+## 什么会被调整尺寸
+
+自动布局只调整图表占用的空间:坐标轴跨度、分类间距、子图尺寸、径向空间、填充布局面积和整体绘图区都可以增大或缩小。数据、语义编码、颜色和格式保持不变。
+
+## 布局模式
+
+通过设置 `baseSize`、`canvasSize` 或两者选择模式:
+
+| 模式 | 设置 | 行为 |
+|------|------|------|
+| **默认自动布局** | 均不设置 | Flint 以 `400 × 320` 为目标,数据密集时可以增大。 |
+| **目标尺寸** | `baseSize` | Flint 以指定尺寸为目标,需要更多空间时仍可增大。 |
+| **固定空间** | `canvasSize` | Flint 将图表放入指定空间且不溢出,适合尺寸固定的仪表盘和卡片。 |
+| **限制增长** | 两者都设置 | Flint 从 `baseSize` 开始,按需增大,但不超过 `canvasSize`。 |
+
+完整公式与实现细节见
+[Auto Layout Algorithm](/documentation/layout-model)。
+
+## 控制预算
+
+探索时使用默认值。设置 `baseSize` 更改目标,仅当周围 UI 有硬性尺寸约束时再添加 `canvasSize` 上限:
+
+```json
+{
+ "chart_spec": {
+ "chartType": "Bar Chart",
+ "encodings": {
+ "x": { "field": "category" },
+ "y": { "field": "value" }
+ },
+ "baseSize": { "width": 480, "height": 320 },
+ "canvasSize": { "width": 720, "height": 480 }
+ },
+ "options": {
+ "elasticity": 0.45,
+ "minStep": 8
+ }
+}
+```
+
+此处图表目标为 `480 × 320`,可拉伸至 `720 × 480` 上限。
+去掉 `canvasSize` 可让图表增长至 `baseSize × maxStretch`,或降低
+`options.maxStretch` 以收紧固定仪表盘单元格的默认上限。
+
+对于**固定槽位**,最简形式是单独设置 `canvasSize` —— Flint 将其视为图表填充且收缩适配、永不溢出的盒子:
+
+```json
+{
+ "chart_spec": {
+ "chartType": "Bar Chart",
+ "encodings": {
+ "x": { "field": "category" },
+ "y": { "field": "value" }
+ },
+ "canvasSize": { "width": 320, "height": 240 }
+ }
+}
+```
+
+要了解多面板布局和这些控件背后的模型,请继续阅读[自动布局算法](/documentation/layout-model)。
diff --git a/docs/zh-CN/tutorials/data-story.md b/docs/zh-CN/tutorials/data-story.md
new file mode 100644
index 00000000..e0d7bd22
--- /dev/null
+++ b/docs/zh-CN/tutorials/data-story.md
@@ -0,0 +1,199 @@
+# 示例:数据故事
+
+简单的折线图足以理解 Flint 规范的结构。当问题变得更复杂时,才真正有趣起来。
+
+本示例使用游戏市场数据集:一组游戏组合的月度活跃用户,按月份、标题、游戏类型和地区拆分。我们将用同一份源数据,以三种方式推进叙事:
+
+- **概览**:用户在哪里?各地区趋势有何不同?
+- **变化**:是什么推动组合上升或下降?
+- **构成**:最新组合按地区、类型和标题如何分解?
+
+先说清楚一点:Flint 是图表编译器,不是完整的 ETL 引擎。它期望你传入的行已经匹配你想绘制的视图。在本故事中,各视图均源自同一数据集,但部分图表使用的是已汇总的、可直接作图的表格。瀑布图有意保持简单:它使用按月的 `period` / `newUsers` 行,模板将首尾两个 period 视为起始/结束柱。
+
+这仍是重点。一旦视图存在,图表请求就保持精简:DataSpec 说明视图字段的含义,ChartSpec 说明如何绘制。
+
+## 从真实源数据开始
+
+源表每个 `period × game × region` 一行,每个标题附带游戏类型:
+
+| period | game | gameType | region | newUsers | totalUsers |
+|--------|------|----------|--------|----------|------------|
+| 2025-01 | Starforge Tactics | PC / Client | N | 5997 | 10173 |
+| 2025-01 | Starforge Tactics | PC / Client | E | 682 | 4475 |
+| 2025-01 | Starforge Tactics | PC / Client | S | -886 | 1917 |
+| 2025-01 | Starforge Tactics | PC / Client | W | -1619 | 605 |
+| 2025-01 | Neon Drift 2049 | Console | N | 8195 | 14920 |
+| ... | ... | ... | ... | ... | ... |
+
+DataSpec 为这些列命名并记录其含义:
+
+```json
+{
+ "data": { "values": [ /* period × game × gameType × region rows */ ] },
+ "semantic_types": {
+ "period": "YearMonth",
+ "game": "Category",
+ "gameType": "Category",
+ "newUsers": "Profit",
+ "totalUsers": "Quantity",
+ "region": { "semanticType": "Category", "sortOrder": ["N", "E", "S", "W"] }
+ }
+}
+```
+
+这些语义标签承载的不只是名称:
+
+- `YearMonth` 告诉 Flint 将 `period` 解析为时间并格式化月份刻度。
+- `Quantity` 为 `totalUsers` 提供数值轴。
+- `Profit` 将 `newUsers` 标记为有符号值,因此热力图可在零附近使用发散色标。
+- `region.sortOrder` 使地区面板按 N、E、S、W 排列,而不是沿用原始表中的任意顺序。
+
+下方示例使用从该源数据派生的、可直接作图的视图:
+
+- 折线视图:按 `region × period × gameType` 对 `sum(totalUsers)` 汇总;
+- 分组柱状视图:按 `period × gameType` 对 `sum(totalUsers)` 汇总;
+- 瀑布视图:按 `period` 对 `sum(newUsers)` 汇总,首尾 period 自动视为起始/结束柱;
+- 热力图视图:按 `game × period` 对 `sum(newUsers)` 汇总;
+- 旭日图视图:最新月份的 `region × gameType × game` 行,以 `totalUsers` 作为大小。
+
+AI 智能体、SQL 查询、notebook 或应用层可以准备这些视图。Flint 随后处理图表相关的编译:坐标轴、标记、颜色、布局与后端语法。
+
+## 第一幕:概览
+
+首先提出一个宽泛问题:用户在哪里?
+
+折线视图包含 `region`、`period`、`gameType` 和 `totalUsers`。图表按地区拆成多个面板,每种游戏类型对应一条线:
+
+```json
+"chart_spec": {
+ "chartType": "Line Chart",
+ "encodings": {
+ "column": { "field": "region" },
+ "x": { "field": "period" },
+ "y": { "field": "totalUsers" },
+ "color": { "field": "gameType" }
+ }
+}
+```
+
+```flint-chart
+{ "generator": "Omni: Line", "canvasSize": { "width": 360, "height": 520 } }
+```
+
+值得注意:
+
+- ChartSpec 指定 `column: region`;Flint 自动将各地区排成多个面板。
+- 因 DataSpec 声明 `YearMonth`,`period` 保持为时间轴。
+- 图表会为多个面板留出足够空间,而不是把它们全挤进 `baseSize`。
+
+接下来将视图从地区趋势面板切换为月度对比。分组柱状视图包含 `period`、`gameType` 和 `totalUsers`:
+
+```json
+"chart_spec": {
+ "chartType": "Grouped Bar Chart",
+ "encodings": {
+ "x": { "field": "period" },
+ "y": { "field": "totalUsers" },
+ "color": { "field": "gameType" },
+ "group": { "field": "gameType" }
+ }
+}
+```
+
+```flint-chart
+{ "generator": "Omni: Grouped Bar", "canvasSize": { "width": 640, "height": 340 }, "options": { "maxStretch": 1 } }
+```
+
+这是第一个回报:只需更换模板和少量通道,图表设计就从多面板折线图变为分组柱状图。虽然数据汇总方式不同,字段语义保持不变。
+
+## 第二幕:变化
+
+概览说明用户在哪里。现在问组合是如何到达那里的。对于这个瀑布图,视图仅为每月一行:`period` 加上组合范围内汇总的月度 `newUsers`。无需显式的 start/end/type 列;模板将第一个 period 视为起始柱,最后一个 period 视为结束柱,中间月份为增量。
+
+ChartSpec 只是映射:
+
+```json
+"chart_spec": {
+ "chartType": "Waterfall Chart",
+ "encodings": {
+ "x": { "field": "period" },
+ "y": { "field": "newUsers" }
+ }
+}
+```
+
+```flint-chart
+{ "generator": "Omni: Waterfall", "canvasSize": { "width": 640, "height": 360 }, "options": { "maxStretch": 1 } }
+```
+
+定制部分在模板中:连接线、起始/结束推断、增量柱,以及后端特定的瀑布语法。规范只命名 period 与度量。
+
+要查看哪些游戏驱动了波动,使用另一个派生视图:按 `game × period` 对 `sum(newUsers)` 汇总。热力图将月份放在 x、游戏放在 y,净用户数映射到颜色:
+
+```json
+"chart_spec": {
+ "chartType": "Heatmap",
+ "encodings": {
+ "x": { "field": "period" },
+ "y": { "field": "game" },
+ "color": { "field": "newUsers" }
+ }
+}
+```
+
+```flint-chart
+{ "generator": "Omni: Heatmap", "canvasSize": { "width": 640, "height": 460 }, "options": { "maxStretch": 1 } }
+```
+
+这里语义类型做了简单图表中容易忽略的工作。因 `newUsers` 被当作 `Profit` 处理,Flint 知道零有意义,可使用红蓝发散色标,而非通用顺序渐变。
+
+## 第三幕:构成
+
+最后问最新组合由什么构成。
+
+旭日图视图过滤到最新月份,保留 `region`、`gameType`、`game` 和 `totalUsers`。ChartSpec 描述层次结构:地区,再游戏类型,再单个游戏,以 total users 作为大小。
+
+```json
+"chart_spec": {
+ "chartType": "Sunburst Chart",
+ "encodings": {
+ "color": { "field": "region" },
+ "group": { "field": "gameType" },
+ "detail": { "field": "game" },
+ "size": { "field": "totalUsers" }
+ }
+}
+```
+
+```flint-chart
+echarts
+{ "generator": "Omni: Sunburst", "canvasSize": { "width": 460, "height": 460 } }
+```
+
+此图也演示了后端切换。Vega-Lite 没有原生 sunburst 图元,因此渲染使用 ECharts。输入仍读起来像 Flint:字段、通道、语义。因设计需要 ECharts 原生图表类型,后端随之改变。
+
+## 本示例说明了什么
+
+同一份源数据集经过五个可直接作图的视图与五种图表设计:
+
+- 多面板折线图展示地区趋势;
+- 分组柱状图进行月度对比;
+- 瀑布图展示组合变化;
+- 热力图展示游戏×月份变动;
+- 旭日图展示层次构成。
+
+三项 Flint 特性承担了大部分图表工作:
+
+- **语义类型决定底层设置。** 时间解析、定量轴、发散颜色和面板顺序都来自 DataSpec。
+- **自动布局保持复杂视图可读。** 多面板图、分组柱状图、密集热力图和径向图都能根据可用画布扩展或缩放。
+- **图表设计和渲染后端都容易切换。** 只要视图包含所需字段,ChartSpec 通常只需修改几行;图表类型受支持时,同一份输入可以编译到 Vega-Lite、ECharts 或 Chart.js。
+
+这就是示例背后的现实承诺:准备正确视图,标注字段含义,然后更换图表设计而无需手写后端规范。
+
+## 下一步
+
+- [入门指南](/documentation/getting-started) 用一张小图介绍 DataSpec 与 ChartSpec。
+- [Gallery](/gallery) 展示每种图表模板与后端组合。
+- [Semantic Type](/documentation/semantic-types) 说明语义标签如何驱动时间解析、颜色与聚合行为等默认值。
+- [自动布局算法](/documentation/layout-model) 说明 Flint 如何为密集、多面板和层次视图计算尺寸。
+- [在线编辑器](/editor) 可实时编辑 Flint 规范。
diff --git a/docs/zh-CN/tutorials/getting-started.md b/docs/zh-CN/tutorials/getting-started.md
new file mode 100644
index 00000000..cf03cb7e
--- /dev/null
+++ b/docs/zh-CN/tutorials/getting-started.md
@@ -0,0 +1,150 @@
+# 入门指南
+
+Flint 的思路很简单:先描述**数据代表什么**,再说明**你想要什么图表**。Flint 会据此生成 Vega-Lite、ECharts 或 Chart.js 可以直接使用的规范。
+
+本页只介绍最基本的流程:安装 Flint、阅读一份完整规范,再将它编译成图表。
+
+## 安装
+
+### JavaScript / TypeScript
+
+安装 Flint:
+
+```bash
+npm install flint-chart
+```
+
+若要在浏览器中渲染 Vega-Lite 图表,还需安装渲染栈:
+
+```bash
+npm install vega vega-lite vega-embed
+```
+
+### Python 规划中
+
+Python 包计划在后续版本发布,不包含在首次公开发版中。目前请使用 JavaScript/TypeScript 包或 MCP 服务器;贡献者仍可从源码使用 Python 移植版。
+
+## 你的第一份 Flint 规范
+
+下面是一份月度注册量折线图的完整输入:
+
+```json
+{
+ "data": {
+ "values": [
+ { "month": "2024-01", "signups": 120 },
+ { "month": "2024-02", "signups": 146 },
+ { "month": "2024-03", "signups": 168 },
+ { "month": "2024-04", "signups": 164 },
+ { "month": "2024-05", "signups": 181 }
+ ]
+ },
+ "semantic_types": {
+ "month": "YearMonth",
+ "signups": "Quantity"
+ },
+ "chart_spec": {
+ "chartType": "Line Chart",
+ "encodings": {
+ "x": { "field": "month" },
+ "y": { "field": "signups" }
+ },
+ "baseSize": { "width": 420, "height": 280 }
+ }
+}
+```
+
+```flint-chart
+{
+ "data": {
+ "values": [
+ { "month": "2024-01", "signups": 120 },
+ { "month": "2024-02", "signups": 146 },
+ { "month": "2024-03", "signups": 168 },
+ { "month": "2024-04", "signups": 164 },
+ { "month": "2024-05", "signups": 181 }
+ ]
+ },
+ "semantic_types": {
+ "month": "YearMonth",
+ "signups": "Quantity"
+ },
+ "chart_spec": {
+ "chartType": "Line Chart",
+ "encodings": {
+ "x": { "field": "month" },
+ "y": { "field": "signups" }
+ },
+ "baseSize": { "width": 420, "height": 280 }
+ }
+}
+```
+
+这份输入分为两部分:
+
+- **DataSpec**:由 `data` 和 `semantic_types` 组成,包含表格数据和每列的语义。`month` 是 `YearMonth`,所以 Flint 会把 `2024-01` 这样的字符串当作日期;`signups` 是 `Quantity`,所以会使用数值轴。如果数据来自杂乱的 CSV、数据库结构或自然语言请求,可以让 AI 智能体起草这部分。详见[智能体工作流](/documentation/agent-workflows)。
+- **ChartSpec**:即 `chart_spec`,描述图表类型和字段位置。这里使用 `Line Chart`,将 `month` 放在 x 轴,将 `signups` 放在 y 轴。
+
+这就是 Flint 的核心:DataSpec 说明数据*是什么*,ChartSpec 说明你想*怎么看*。将 JSON 粘贴到[在线编辑器](/editor)即可实时查看和修改。
+
+## 编译
+
+在 JavaScript 或 TypeScript 中,将同一份输入传给编译函数:
+
+```ts
+import { assembleVegaLite } from 'flint-chart';
+
+const input = {
+ data: {
+ values: [
+ { month: '2024-01', signups: 120 },
+ { month: '2024-02', signups: 146 },
+ { month: '2024-03', signups: 168 },
+ { month: '2024-04', signups: 164 },
+ { month: '2024-05', signups: 181 },
+ ],
+ },
+ semantic_types: {
+ month: 'YearMonth',
+ signups: 'Quantity',
+ },
+ chart_spec: {
+ chartType: 'Line Chart',
+ encodings: {
+ x: { field: 'month' },
+ y: { field: 'signups' },
+ },
+ baseSize: { width: 420, height: 280 },
+ },
+};
+
+const spec = assembleVegaLite(input);
+```
+
+Flint 返回一份普通的 Vega-Lite 规范。使用 Vega-Embed 渲染:
+
+```ts
+import embed from 'vega-embed';
+
+await embed('#chart', spec);
+```
+
+同一份 Flint 输入也可以编译为其他后端的配置:
+
+```ts
+import { assembleChartjs, assembleECharts } from 'flint-chart';
+
+const chartjsConfig = assembleChartjs(input);
+const echartsOption = assembleECharts(input);
+```
+
+Python 支持将使用相同的输入结构,计划在后续版本发布。
+
+## 接下来读什么
+
+- [示例:数据故事](/documentation/data-story):用同一份 DataSpec 和五种 ChartSpec 生成不同图表。
+- [配置 Flint MCP](/documentation/setup-flint-mcp):在聊天工具或 IDE 中连接 Flint MCP。
+- [智能体工作流](/documentation/agent-workflows):将 Flint 集成到自己的智能体产品中。
+- [语义类型](/documentation/semantic-types):了解 `YearMonth`、`Quantity`、`Category` 和 `Profit` 等语义标签。
+- [图表示例](/gallery):浏览 Vega-Lite、ECharts 和 Chart.js 支持的图表模板。
+- [概览](/documentation/overview):进一步了解 Flint 的整体设计和架构。
diff --git a/docs/zh-CN/tutorials/setup-flint-mcp.md b/docs/zh-CN/tutorials/setup-flint-mcp.md
new file mode 100644
index 00000000..48d31abb
--- /dev/null
+++ b/docs/zh-CN/tutorials/setup-flint-mcp.md
@@ -0,0 +1,198 @@
+# 配置 Flint MCP
+
+本页介绍如何在 MCP 客户端中配置 `flint-chart-mcp`,以及服务器提供哪些工具和资源。
+
+如果你想让 VS Code、Claude Desktop、Cursor 或其他 MCP 客户端中的智能体创建 Flint 图表,请从这里开始。如果要将 Flint 库直接集成到自己的智能体产品中,请参阅[智能体工作流](/documentation/agent-workflows)。
+
+## MCP 服务器提供什么
+
+智能体负责生成 `ChartAssemblyInput`,MCP 服务器负责在本地校验、编译和渲染图表。
+
+| 工具 | 用途 |
+|------|------|
+| `create_chart_view` | 打开交互式图表视图,提供实时 SVG 预览和图表选项。客户端支持 MCP Apps 时优先使用。 |
+| `validate_chart` | 检查 Flint 输入,返回警告、错误和计算后的图表尺寸。 |
+| `render_chart` | 渲染静态 PNG 或 SVG。客户端不支持 MCP Apps,或需要导出图片时使用。 |
+| `compile_chart` | 生成可供 Vega-Lite、ECharts 或 Chart.js 直接使用的 JSON 规范。 |
+| `list_chart_types` | 列出支持的图表类型和编码通道。 |
+
+| 资源或提示词 | 用途 |
+|--------------------|------|
+| `flint://agent-skill` | 加载随服务器提供的 Flint 图表编写指南。 |
+| `flint://chart-types` | 浏览支持的图表目录。 |
+| `ui://flint-chart/chart-view.html` | `create_chart_view` 使用的 MCP App 界面资源。 |
+| `author_flint_chart` | 加载 Flint 图表编写指南的提示词。 |
+
+调用图表工具前,请让客户端加载 `flint://agent-skill`,或运行 `author_flint_chart` 提示词。编写指南包含有效的 `chartType` 名称、字段与通道的对应关系、语义类型、数据绑定规则,以及各渲染工具的适用场景。
+
+## 要求
+
+你需要:
+
+- 能运行 stdio 服务器的 MCP 客户端;
+- 该客户端可用的 Node.js 与 npm;
+- 图表数据直接嵌入工具调用,或从本地文件读取。
+
+服务器在本地渲染图表,不会把内联数据或本地文件上传到远程渲染服务。
+
+## 使用 `npx` 运行
+
+多数客户端可通过 `npx` 运行已发布包,无需全局安装:
+
+```bash
+npx -y flint-chart-mcp
+```
+
+该命令会启动 stdio MCP 服务器。通常只需把它写入 MCP 客户端配置,无需手动运行。
+
+## 配置 VS Code
+
+在 VS Code 中,于 `.vscode/mcp.json` 添加服务器条目:
+
+```jsonc
+{
+ "servers": {
+ "flint": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "flint-chart-mcp"]
+ }
+ }
+}
+```
+
+默认配置允许智能体通过 `data.url` 读取本地 `.csv`、`.tsv` 或 `.json` 文件。如果运行环境不可信,请使用 `--disable-file-reference` 禁用本地文件引用。此时智能体必须通过 `data.values` 直接传入数据:
+
+```jsonc
+{
+ "servers": {
+ "flint": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "flint-chart-mcp", "--disable-file-reference"]
+ }
+ }
+}
+```
+
+修改服务器代码或配置后,请在客户端重启 MCP 服务器。可以让智能体调用 `list_chart_types`,确认连接是否正常。
+
+## 配置 Claude Desktop 或 Cursor
+
+许多 MCP 客户端使用 `mcpServers` 对象:
+
+```jsonc
+{
+ "mcpServers": {
+ "flint": {
+ "command": "npx",
+ "args": ["-y", "flint-chart-mcp"]
+ }
+ }
+}
+```
+
+要完全禁用本地文件读取(仅内联 `data.values`):
+
+```jsonc
+{
+ "mcpServers": {
+ "flint": {
+ "command": "npx",
+ "args": ["-y", "flint-chart-mcp", "--disable-file-reference"]
+ }
+ }
+}
+```
+
+## 从本仓库运行
+
+开发 Flint 本身时,构建各包并将客户端指向本地 CLI。MCP 包依赖 `flint-chart`,因此需同时构建两者(根目录 `build` 脚本先构建 `flint-js`,再构建 `flint-mcp`):
+
+```bash
+npm install
+npm run build
+```
+
+若库已构建,仅需重建 MCP 包时,运行 `npm run build:mcp`。
+
+VS Code 本地源码配置:
+
+```jsonc
+{
+ "servers": {
+ "flint": {
+ "type": "stdio",
+ "command": "node",
+ "args": [
+ "${workspaceFolder}/packages/flint-mcp/dist/cli.js"
+ ]
+ }
+ }
+}
+```
+
+修改服务器代码、渲染代码或捆绑的 MCP App UI 后,请重建并重启 MCP 服务器。
+
+## 数据访问
+
+MCP 工具调用可通过两种方式绑定数据:
+
+- **直接传入数据:** 在工具调用中设置 `data: { values: [...] }`,适合较小或已经整理好的表格。
+- **读取本地文件:** 使用 `data: { url: "..." }` 引用本地 `.json`、`.csv` 或 `.tsv` 文件。
+
+默认情况下,服务器可以读取智能体指定的本地文件;相对路径从当前工作目录开始解析。服务器不会读取远程 URL。
+
+对于不可信部署,使用 `--disable-file-reference` 完全拒绝本地文件引用。智能体须通过 `data.values` 内联传入行:
+
+```bash
+npx -y flint-chart-mcp --disable-file-reference
+FLINT_MCP_DISABLE_FILE_REFERENCE=1 npx -y flint-chart-mcp
+```
+
+若图表请求需要聚合、过滤、连接、透视、派生列或长表重塑,请让智能体在调用 Flint 前准备可直接作图的表格。Flint 编译图表;它不是通用数据整理引擎。
+
+## 后端与渲染选项
+
+服务器支持以下后端:
+
+- `vegalite` 用于语法式统计图表;
+- `echarts` 用于更丰富的交互与层次图表类型;
+- `chartjs` 用于熟悉的 canvas 图表。Chart.js 仅渲染 PNG。
+
+可在启动时只暴露子集:
+
+```bash
+npx -y flint-chart-mcp --backends vegalite,echarts
+FLINT_MCP_BACKENDS=vegalite,echarts npx -y flint-chart-mcp
+```
+
+客户端支持 MCP Apps 时,使用 `create_chart_view` 查看和调整图表。需要静态图片时使用 `render_chart`。需要在其他渲染器或编辑器中继续使用时,通过 `compile_chart` 生成对应的 JSON 规范。
+
+## 验证配置
+
+在 MCP 客户端中,请智能体做简单验证:
+
+```text
+Load flint://agent-skill or run the author_flint_chart prompt.
+Then call list_chart_types for the vegalite backend and tell me whether Flint is connected.
+```
+
+然后尝试第一张图表:
+
+```text
+Use Flint MCP to create a bar chart from these rows:
+[{"region":"North","revenue":120},{"region":"South","revenue":90}]
+Use region as Category and revenue as Quantity.
+Open it with create_chart_view if this client supports MCP Apps; otherwise render an SVG.
+```
+
+如果 `list_chart_types` 可用,但无法读取本地文件,请检查文件路径以及是否设置了 `--disable-file-reference`。如果 `create_chart_view` 不可用,客户端可能不支持 MCP Apps;请改用 `render_chart`。
+
+## 下一步
+
+- [智能体工作流](/documentation/agent-workflows) 说明如何将 Flint 的语义图表契约嵌入自定义智能体或智能体产品。
+- [入门指南](/documentation/getting-started) 用一张小图解释 `DataSpec` 与 `ChartSpec` 的结构。
+- [Vega-Lite 图表](/documentation/reference-vegalite)、
+ [ECharts 图表](/documentation/reference-echarts) 和
+ [Chart.js 图表](/documentation/reference-chartjs) 按后端列出支持的图表类型。
diff --git a/package-lock.json b/package-lock.json
index f9300bbf..5ed9d821 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5122,6 +5122,15 @@
"node": ">=16.9.0"
}
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -5152,6 +5161,34 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/i18next": {
+ "version": "26.3.6",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
+ "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -7409,6 +7446,33 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.10",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
+ "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.2.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -8421,7 +8485,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -8603,6 +8667,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -9331,6 +9404,15 @@
"node": ">=18"
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
@@ -9511,7 +9593,7 @@
},
"packages/flint-js": {
"name": "flint-chart",
- "version": "0.2.2",
+ "version": "0.3.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.14.10",
@@ -9552,7 +9634,7 @@
},
"packages/flint-mcp": {
"name": "flint-chart-mcp",
- "version": "0.2.2",
+ "version": "0.3.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.7.4",
@@ -9561,7 +9643,7 @@
"@resvg/resvg-js": "^2.6.2",
"chart.js": "^4.4.0",
"echarts": "^6.0.0",
- "flint-chart": "^0.2.2",
+ "flint-chart": "^0.3.0",
"vega": "^6.0.0",
"vega-interpreter": "^2.2.1",
"vega-lite": "^6.0.0",
@@ -9759,9 +9841,11 @@
"chart.js": "^4.5.1",
"echarts": "^6.0.0",
"flint-chart": "*",
+ "i18next": "^26.3.6",
"katex": "^0.17.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-i18next": "^17.0.10",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.30.4",
"react-syntax-highlighter": "^16.1.1",
diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json
index f78d096b..f19123cd 100644
--- a/packages/flint-js/package.json
+++ b/packages/flint-js/package.json
@@ -1,6 +1,6 @@
{
"name": "flint-chart",
- "version": "0.2.2",
+ "version": "0.3.0",
"description": "Semantic-level visualization library that compiles data + semantic types into chart specs for Vega-Lite, ECharts, and Chart.js.",
"keywords": [
"visualization",
diff --git a/packages/flint-js/src/chart-types/gantt.ts b/packages/flint-js/src/chart-types/gantt.ts
new file mode 100644
index 00000000..798773f8
--- /dev/null
+++ b/packages/flint-js/src/chart-types/gantt.ts
@@ -0,0 +1,85 @@
+import { SemanticAnnotation, toTypeString } from '../core/field-semantics';
+import { isTimeSeriesType } from '../core/semantic-types';
+import { ChartPropertyDef } from '../core/types';
+
+export interface GanttRow {
+ task: string;
+ start: number;
+ end: number;
+ inputIndex: number;
+}
+
+export const GANTT_PROPERTIES: ChartPropertyDef[] = [
+ { key: 'taskHeight', label: 'Task height', type: 'continuous', min: 40, max: 90, step: 5, defaultValue: 70 },
+ { key: 'cornerRadius', label: 'Corners', type: 'continuous', min: 0, max: 8, step: 1, defaultValue: 2 },
+ { key: 'intervalLabels', label: 'Labels', type: 'binary', defaultValue: false },
+];
+
+export function sortGanttRows(rows: T[]): T[] {
+ return [...rows].sort((a, b) => (a.start - b.start) || (a.inputIndex - b.inputIndex));
+}
+
+export function coerceGanttEndpoint(value: unknown, temporal: boolean): number {
+ if (value == null) return NaN;
+ if (!temporal) return Number(value);
+ if (value instanceof Date) return value.getTime();
+ if (typeof value === 'number') return Math.abs(value) < 1e11 ? value * 1000 : value;
+ return Date.parse(String(value));
+}
+
+export function isGanttTemporal(
+ resolvedType: unknown,
+ semanticType: string | SemanticAnnotation | undefined,
+): boolean {
+ if (resolvedType === 'temporal') return true;
+ const typeName = toTypeString(semanticType);
+ return typeName ? isTimeSeriesType(typeName) : false;
+}
+
+function compactNumber(value: number): string {
+ return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
+}
+
+const GANTT_DURATION_UNITS = [
+ { minimumMs: 86_400_000, divisorMs: 86_400_000, suffix: 'd' },
+ { minimumMs: 3_600_000, divisorMs: 3_600_000, suffix: 'h' },
+ { minimumMs: 60_000, divisorMs: 60_000, suffix: 'min' },
+ { minimumMs: 1_000, divisorMs: 1_000, suffix: 's' },
+ { minimumMs: 0, divisorMs: 1, suffix: 'ms' },
+] as const;
+
+export function formatGanttDuration(durationMs: number): string {
+ const unit = GANTT_DURATION_UNITS.find(({ minimumMs }) => Math.abs(durationMs) >= minimumMs)!;
+ return `${compactNumber(durationMs / unit.divisorMs)}${unit.suffix}`;
+}
+
+export function ganttDurationLabelExpression(start: string, end: string, temporal: boolean): string {
+ const startValue = `datum[${JSON.stringify(start)}]`;
+ const endValue = `datum[${JSON.stringify(end)}]`;
+ if (!temporal) return `format(${endValue} - ${startValue}, ',.2~f')`;
+
+ const duration = `(toDate(${endValue}) - toDate(${startValue}))`;
+ return GANTT_DURATION_UNITS.reduceRight((fallback, unit, index) => {
+ const label = `format(${duration} / ${unit.divisorMs}, '.2~f') + '${unit.suffix}'`;
+ return index === GANTT_DURATION_UNITS.length - 1
+ ? label
+ : `(abs(${duration}) >= ${unit.minimumMs} ? ${label} : ${fallback})`;
+ }, '');
+}
+
+export function formatGanttLabel(
+ start: number,
+ end: number,
+ temporal: boolean,
+): string {
+ const duration = end - start;
+ if (!temporal) return compactNumber(duration);
+ return formatGanttDuration(duration);
+}
+
+export function ganttLabelReservePx(rows: Pick[], temporal: boolean): number {
+ const maxCharacters = rows.reduce((max, row) => (
+ Math.max(max, formatGanttLabel(row.start, row.end, temporal).length)
+ ), 0);
+ return Math.max(40, maxCharacters * 7 + 10);
+}
\ No newline at end of file
diff --git a/packages/flint-js/src/core/waterfall.ts b/packages/flint-js/src/chart-types/waterfall.ts
similarity index 99%
rename from packages/flint-js/src/core/waterfall.ts
rename to packages/flint-js/src/chart-types/waterfall.ts
index e4e5358f..a28ff171 100644
--- a/packages/flint-js/src/core/waterfall.ts
+++ b/packages/flint-js/src/chart-types/waterfall.ts
@@ -53,4 +53,4 @@ export function resolveTotalsMode(values: number[], explicit?: unknown): Waterfa
return explicit;
}
return recommendedTotalsMode(values);
-}
+}
\ No newline at end of file
diff --git a/packages/flint-js/src/chartjs/index.ts b/packages/flint-js/src/chartjs/index.ts
index 707dd2d7..1b3944dd 100644
--- a/packages/flint-js/src/chartjs/index.ts
+++ b/packages/flint-js/src/chartjs/index.ts
@@ -32,4 +32,4 @@ export {
} from './templates';
// CJS recommendation & adaptation
-export { cjsAdaptChart, cjsRecommendEncodings } from './recommendation';
+export { cjsAdaptChart, cjsRecommendEncodings, cjsRecommendChartTypes, cjsRecommendCharts } from './recommendation';
diff --git a/packages/flint-js/src/chartjs/recommendation.ts b/packages/flint-js/src/chartjs/recommendation.ts
index 85b56487..c318a61d 100644
--- a/packages/flint-js/src/chartjs/recommendation.ts
+++ b/packages/flint-js/src/chartjs/recommendation.ts
@@ -6,7 +6,12 @@
*/
import { adaptChannels, recommendChannels } from '../core/recommendation';
-import { cjsGetTemplateChannels } from './templates';
+import {
+ recommendChartTypes,
+ type RecommendChartTypesOptions,
+ type RecommendedChart,
+} from '../core/chart-type-recommendation';
+import { cjsGetTemplateChannels, cjsAllTemplateDefs } from './templates';
export function cjsAdaptChart(
sourceType: string,
@@ -32,3 +37,36 @@ export function cjsRecommendEncodings(
}
return result;
}
+
+/** Chart-type names Chart.js can render (its template catalog). */
+const CJS_SUPPORTED_TYPES = cjsAllTemplateDefs.map(d => d.chart);
+
+/**
+ * Recommend a ranked list of Chart.js chart types for a dataset, restricted to
+ * chart types Chart.js can render. See {@link cjsRecommendCharts} for the
+ * one-step variant that also fills channels.
+ */
+export function cjsRecommendChartTypes(
+ data: any[],
+ semanticTypes: Record,
+ options: Omit = {},
+): string[] {
+ return recommendChartTypes(data, semanticTypes, { ...options, supportedTypes: CJS_SUPPORTED_TYPES });
+}
+
+/**
+ * One-step recommendation: rank Chart.js chart types for the data, then populate
+ * each with {@link cjsRecommendEncodings}. Suggestions whose required channels
+ * cannot be filled are dropped, so every returned chart is renderable.
+ */
+export function cjsRecommendCharts(
+ data: any[],
+ semanticTypes: Record,
+ options: Omit = {},
+): RecommendedChart[] {
+ const { max, ...rest } = options;
+ const charts = cjsRecommendChartTypes(data, semanticTypes, rest)
+ .map(chartType => ({ chartType, encodings: cjsRecommendEncodings(chartType, data, semanticTypes) }))
+ .filter(s => Object.keys(s.encodings).length > 0);
+ return max != null ? charts.slice(0, max) : charts;
+}
diff --git a/packages/flint-js/src/chartjs/templates/area.ts b/packages/flint-js/src/chartjs/templates/area.ts
index d008ea5c..7bc6fcbe 100644
--- a/packages/flint-js/src/chartjs/templates/area.ts
+++ b/packages/flint-js/src/chartjs/templates/area.ts
@@ -172,7 +172,12 @@ export const cjsAreaChartDef: ChartTemplateDef = {
{ value: 'monotone', label: 'Monotone (smooth)' },
],
} as ChartPropertyDef,
- { key: 'opacity', label: 'Opacity', type: 'continuous', min: 0.1, max: 1, step: 0.05, defaultValue: 0.4 } as ChartPropertyDef,
+ {
+ key: 'opacity', label: 'Opacity', type: 'continuous', min: 0.1, max: 1, step: 0.05, defaultValue: 0.4,
+ check: (ctx) => ({
+ applicable: !!ctx.encodings.color?.field && ctx.chartProperties?.stackMode === 'layered',
+ }),
+ } as ChartPropertyDef,
{
key: 'stackMode', label: 'Stack', type: 'discrete', options: [
{ value: undefined, label: 'Stacked (default)' },
diff --git a/packages/flint-js/src/chartjs/templates/bar.ts b/packages/flint-js/src/chartjs/templates/bar.ts
index 856ce5d6..456fe2cd 100644
--- a/packages/flint-js/src/chartjs/templates/bar.ts
+++ b/packages/flint-js/src/chartjs/templates/bar.ts
@@ -114,7 +114,7 @@ export const cjsBarChartDef: ChartTemplateDef = {
pivot: makeCartesianPivot({
transpose: [['x', 'y']],
permute: [['x', 'y', 'color']],
- shift: ['color', 'group', 'column', 'row'],
+ shift: ['color', 'column', 'row'],
}),
};
@@ -218,15 +218,7 @@ export const cjsStackedBarChartDef: ChartTemplateDef = {
transpose: [['x', 'y']],
permute: [['x', 'y', 'color']],
shift: ['color', 'group', 'column', 'row'],
- transitions: [
- {
- to: 'Grouped Bar Chart',
- label: 'Grouped',
- route: { from: 'color', to: 'group', mode: 'move' },
- requireDiscreteSource: true,
- maxSourceCardinality: 12,
- },
- ],
+ // θ (→ Grouped Bar) declared centrally in core/chart-transitions.ts.
}),
};
@@ -426,13 +418,6 @@ export const cjsGroupedBarChartDef: ChartTemplateDef = {
transpose: [['x', 'y']],
permute: [['x', 'y', 'color']],
shift: ['color', 'group', 'column', 'row'],
- transitions: [
- {
- to: 'Stacked Bar Chart',
- label: 'Stacked',
- route: { from: 'group', to: 'color', mode: 'move' },
- requireDiscreteSource: true,
- },
- ],
+ // θ (→ Stacked Bar) declared centrally in core/chart-transitions.ts.
}),
};
diff --git a/packages/flint-js/src/chartjs/templates/ecdf.ts b/packages/flint-js/src/chartjs/templates/ecdf.ts
index b8054e1b..0a6051cc 100644
--- a/packages/flint-js/src/chartjs/templates/ecdf.ts
+++ b/packages/flint-js/src/chartjs/templates/ecdf.ts
@@ -115,6 +115,6 @@ export const cjsEcdfPlotDef: ChartTemplateDef = {
delete spec.encoding;
},
properties: [
- { key: 'showPoints', label: 'Show points', type: 'binary', defaultValue: false } as ChartPropertyDef,
+ { key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false } as ChartPropertyDef,
],
};
diff --git a/packages/flint-js/src/chartjs/templates/gantt.ts b/packages/flint-js/src/chartjs/templates/gantt.ts
index e4cbbf7f..ac26c7e4 100644
--- a/packages/flint-js/src/chartjs/templates/gantt.ts
+++ b/packages/flint-js/src/chartjs/templates/gantt.ts
@@ -20,7 +20,15 @@
*/
import { ChartTemplateDef } from '../../core/types';
-import { coerceUnixMsForChartJs, DEFAULT_COLORS, DEFAULT_BG_COLORS } from './utils';
+import {
+ coerceGanttEndpoint,
+ formatGanttLabel,
+ ganttLabelReservePx,
+ GANTT_PROPERTIES,
+ isGanttTemporal,
+ sortGanttRows,
+} from '../../chart-types/gantt';
+import { DEFAULT_COLORS, DEFAULT_BG_COLORS } from './utils';
function fmtDate(ms: number): string {
if (!Number.isFinite(ms)) return '';
@@ -34,25 +42,29 @@ export const cjsGanttChartDef: ChartTemplateDef = {
markCognitiveChannel: 'position',
declareLayoutMode: () => ({ axisFlags: { y: { banded: true } } }),
instantiate: (spec, ctx) => {
- const { channelSemantics, table } = ctx;
+ const { channelSemantics, table, chartProperties, semanticTypes } = ctx;
const taskField = channelSemantics.y?.field;
const startField = channelSemantics.x?.field;
const endField = channelSemantics.x2?.field;
const colorField = channelSemantics.color?.field;
if (!taskField || !startField || !endField || table.length === 0) return;
- const temporal = channelSemantics.x?.type === 'temporal';
- const num = (v: unknown) => (temporal ? coerceUnixMsForChartJs(v) : Number(v));
+ const temporal = isGanttTemporal(channelSemantics.x?.type, semanticTypes[startField]);
+ const num = (v: unknown) => coerceGanttEndpoint(v, temporal);
- const rows = table
- .map((r: any) => ({
+ const rows = sortGanttRows(table
+ .map((r: any, inputIndex: number) => ({
task: String(r[taskField] ?? ''),
start: num(r[startField]),
end: num(r[endField]),
group: colorField != null ? String(r[colorField] ?? '') : undefined,
+ inputIndex,
}))
- .filter((r) => r.task && Number.isFinite(r.start) && Number.isFinite(r.end))
- .sort((a, b) => a.start - b.start);
+ .filter((r) => r.task && Number.isFinite(r.start) && Number.isFinite(r.end)));
+ const taskHeight = chartProperties?.taskHeight ?? 70;
+ const cornerRadius = chartProperties?.cornerRadius ?? 2;
+ const intervalLabels = chartProperties?.intervalLabels === true;
+ const labelReserve = ganttLabelReservePx(rows, temporal);
const groups = colorField
? Array.from(new Set(rows.map((r) => r.group ?? '')))
@@ -83,14 +95,18 @@ export const cjsGanttChartDef: ChartTemplateDef = {
backgroundColor: bg,
borderColor: border,
borderWidth: 1,
- borderRadius: 2,
+ borderRadius: cornerRadius,
borderSkipped: false,
+ barPercentage: taskHeight / 100,
}],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
+ ...(intervalLabels
+ ? { layout: { padding: { right: labelReserve } } }
+ : {}),
scales: {
x: {
beginAtZero: false,
@@ -132,8 +148,33 @@ export const cjsGanttChartDef: ChartTemplateDef = {
},
};
+ if (intervalLabels) {
+ config.plugins = [{
+ id: 'ganttLabels',
+ afterDatasetsDraw: (chart: any) => {
+ const { ctx: canvasContext } = chart;
+ canvasContext.save();
+ canvasContext.fillStyle = '#333333';
+ canvasContext.font = '10px sans-serif';
+ canvasContext.textAlign = 'left';
+ canvasContext.textBaseline = 'middle';
+ chart.getDatasetMeta(0).data.forEach((bar: any, index: number) => {
+ const row = rows[index];
+ if (!row) return;
+ canvasContext.fillText(
+ formatGanttLabel(row.start, row.end, temporal),
+ bar.x + 4,
+ bar.y,
+ );
+ });
+ canvasContext.restore();
+ },
+ }];
+ }
+
Object.assign(spec, config);
delete spec.mark;
delete spec.encoding;
},
+ properties: GANTT_PROPERTIES,
};
diff --git a/packages/flint-js/src/chartjs/templates/jitter.ts b/packages/flint-js/src/chartjs/templates/jitter.ts
index e1b235d5..8de6dd99 100644
--- a/packages/flint-js/src/chartjs/templates/jitter.ts
+++ b/packages/flint-js/src/chartjs/templates/jitter.ts
@@ -181,12 +181,6 @@ export const cjsStripPlotDef: ChartTemplateDef = {
{ key: 'opacity', label: 'Opacity', type: 'continuous', min: 0, max: 1, step: 0.05, defaultValue: 0 },
] as ChartPropertyDef[],
pivot: makeCartesianPivot({
- transitions: [
- {
- to: 'Scatter Plot',
- label: 'Scatter',
- route: { from: 'color', to: 'x', mode: 'swap', spill: 'color' },
- },
- ],
+ // θ (Strip → Scatter) declared centrally in core/chart-transitions.ts.
}),
};
\ No newline at end of file
diff --git a/packages/flint-js/src/chartjs/templates/scatter.ts b/packages/flint-js/src/chartjs/templates/scatter.ts
index 4de2be96..29413a59 100644
--- a/packages/flint-js/src/chartjs/templates/scatter.ts
+++ b/packages/flint-js/src/chartjs/templates/scatter.ts
@@ -120,13 +120,7 @@ export const cjsScatterPlotDef: ChartTemplateDef = {
transpose: [['x', 'y']],
permute: [['x', 'y', 'color', 'size']],
shift: ['color', 'group', 'column', 'row'],
- transitions: [
- {
- to: 'Strip Plot',
- label: 'Jitter',
- route: { from: 'series', to: 'x', mode: 'swap', spill: 'color' },
- },
- ],
+ // θ transitions declared centrally in core/chart-transitions.ts.
}),
postProcess: (option, ctx) => {
if (!option.data?.datasets) return;
diff --git a/packages/flint-js/src/chartjs/templates/waterfall.ts b/packages/flint-js/src/chartjs/templates/waterfall.ts
index 0a78dadc..f6c252bf 100644
--- a/packages/flint-js/src/chartjs/templates/waterfall.ts
+++ b/packages/flint-js/src/chartjs/templates/waterfall.ts
@@ -18,7 +18,7 @@
*/
import { ChartTemplateDef } from '../../core/types';
-import { resolveTotalsMode } from '../../core/waterfall';
+import { resolveTotalsMode } from '../../chart-types/waterfall';
import { extractCategories } from './utils';
const COLOR = {
@@ -46,7 +46,7 @@ export const cjsWaterfallChartDef: ChartTemplateDef = {
.filter(Boolean) as any[];
const values = rows.map((r) => Number(r[valField]) || 0);
- // Data-aware totals default (see core/waterfall.ts): first is a start
+ // Data-aware totals default (see chart-types/waterfall.ts): first is a start
// total, last is a total only when it reconciles with the prior
// cumulative. The user's `totals` property overrides this; an explicit
// color/type column is authoritative. Mirror vegalite/echarts templates.
diff --git a/packages/flint-js/src/core/band-dodge.ts b/packages/flint-js/src/core/band-dodge.ts
index fe84a949..feab6b5b 100644
--- a/packages/flint-js/src/core/band-dodge.ts
+++ b/packages/flint-js/src/core/band-dodge.ts
@@ -42,8 +42,7 @@ export interface BandDodgePlan {
/** Lanes a *global* offset scale reserves per band = global distinct
* sub-values (the `global` mode lane count). */
laneCount: number;
- /** True when a user override is worth surfacing (any real dodge choice,
- * i.e. `maxPerBand > 1`). */
+ /** True when local and global dodge can produce different layouts. */
ambiguous: boolean;
/** Most distinct sub-values co-occurring within any single band. */
maxPerBand: number;
@@ -109,9 +108,11 @@ export function planBandDodge(
const bandCount = perBand.size;
let maxPerBand = 0;
let singleValuedBands = 0;
+ let completeBands = 0;
for (const bandSet of perBand.values()) {
if (bandSet.size > maxPerBand) maxPerBand = bandSet.size;
if (bandSet.size <= 1) singleValuedBands++;
+ if (bandSet.size === globalCount) completeBands++;
}
const threshold = options?.nestedSnapThreshold ?? DEFAULT_NESTED_SNAP_THRESHOLD;
@@ -122,7 +123,7 @@ export function planBandDodge(
mode,
dodge: mode !== 'none',
laneCount: globalCount,
- ambiguous: maxPerBand > 1,
+ ambiguous: maxPerBand > 1 && completeBands < bandCount,
maxPerBand,
global: globalCount,
bandCount,
diff --git a/packages/flint-js/src/core/chart-transitions.ts b/packages/flint-js/src/core/chart-transitions.ts
new file mode 100644
index 00000000..09035f33
--- /dev/null
+++ b/packages/flint-js/src/core/chart-transitions.ts
@@ -0,0 +1,166 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * Central chart-type **transition registry** — the θ graph.
+ *
+ * This is the single source of truth for which sibling chart types a given chart
+ * can re-render as (Control B / `θ` in the two-control transform model). It
+ * replaces the per-template, per-backend inline `transitions` arrays: a chart
+ * template no longer declares what it can turn into — the compiler looks the
+ * edges up here by chart-type display name.
+ *
+ * Design notes (design-docs/chart-transform-two-axes.md §4, and the "registry"
+ * discussion):
+ * - Edges are keyed by the *authored* chart type's display name.
+ * - Each edge is a CANDIDATE. It is still gated at runtime against the live
+ * encoding + data (route feasibility + the declarative gates on
+ * `PivotTransition`: requireOrderedAxis / requireNonNegative /
+ * maxCategoryCardinality / requireNoSeries / requireDiscreteSource /
+ * maxSourceCardinality) AND against backend availability (an edge is hidden
+ * when the target template does not exist in the active backend's registry).
+ * - So "not all mappings make sense" is handled twice: only sensible edges are
+ * declared here (the §4 catalog), and even a declared edge is withheld when
+ * the data / backend does not support it.
+ * - Edges should be *reversible*: if A → B is declared, B → A generally should
+ * be too (verified by tests), so a transform round-trips home.
+ *
+ * Grouped by data-signature family (design doc §4).
+ */
+
+import { PivotTransition } from './types';
+
+export const CHART_TRANSITIONS: Record = {
+ // ── Categorical comparison — D × M (§4.1) ──────────────────────────────
+ 'Bar Chart': [
+ // Ordered-axis bridge into the trend family (§4.9). Only when the domain
+ // axis is temporal/ordinal — an unordered nominal bar never sprouts a line.
+ // orientDomainAxis:'x' re-orients a horizontal bar so time stays horizontal.
+ { to: 'Line Chart', label: 'Line', requireOrderedAxis: true, orientDomainAxis: 'x' },
+ { to: 'Area Chart', label: 'Area', requireOrderedAxis: true, requireNonNegative: true, orientDomainAxis: 'x' },
+ // Same D×M signature, lighter ink.
+ { to: 'Lollipop Chart', label: 'Lollipop' },
+ ],
+ 'Lollipop Chart': [
+ { to: 'Bar Chart', label: 'Bar' },
+ ],
+ 'Grouped Bar Chart': [
+ {
+ to: 'Stacked Bar Chart',
+ label: 'Stacked',
+ route: { from: 'group', to: 'color', mode: 'move' },
+ requireDiscreteSource: true,
+ },
+ // A 2-sided grouped bar reads as a population pyramid (mirrored).
+ {
+ to: 'Pyramid Chart',
+ label: 'Pyramid',
+ route: { from: 'group', to: 'color', mode: 'move' },
+ requireDiscreteSource: true,
+ maxSourceCardinality: 2,
+ },
+ ],
+ 'Stacked Bar Chart': [
+ {
+ to: 'Grouped Bar Chart',
+ label: 'Grouped',
+ route: { from: 'color', to: 'group', mode: 'move' },
+ requireDiscreteSource: true,
+ maxSourceCardinality: 12,
+ },
+ ],
+ // Population pyramid = a 2-sided category × measure; its complement is the
+ // side-by-side grouped bar (the 2 sides dodged instead of mirrored).
+ 'Pyramid Chart': [
+ {
+ to: 'Grouped Bar Chart',
+ label: 'Grouped',
+ route: { from: 'color', to: 'group', mode: 'move' },
+ requireDiscreteSource: true,
+ },
+ ],
+
+ // ── Trend over an ordered domain — T × M (§4.2) ────────────────────────
+ 'Line Chart': [
+ { to: 'Area Chart', label: 'Area', requireNonNegative: true },
+ // Back to discrete-period comparison; only readable with few ticks.
+ { to: 'Bar Chart', label: 'Bar', maxCategoryCardinality: 30 },
+ // Small-multiple trend strips (one per series) — needs a series. Route
+ // the series onto `color` (from wherever it sits — color OR a column/row
+ // facet) so the Sparkline template picks it up as its row series.
+ { to: 'Sparkline', label: 'Sparklines', requireSeries: true, route: { from: 'series', to: 'color', mode: 'move' } },
+ ],
+ 'Area Chart': [
+ { to: 'Line Chart', label: 'Line' },
+ { to: 'Bar Chart', label: 'Bar', maxCategoryCardinality: 30 },
+ { to: 'Streamgraph', label: 'Stream', requireSeries: true, requireNonNegative: true, route: { from: 'series', to: 'color', mode: 'move' } },
+ ],
+ // Small-multiple trend table → a single overlaid multi-series line.
+ 'Sparkline': [
+ { to: 'Line Chart', label: 'Line' },
+ ],
+ // Flowing composition → back to baseline-anchored trend / area. Both reads
+ // are safe; note Streamgraph → Line is intentionally *one-directional* (there
+ // is no Line → Streamgraph — see the note above).
+ 'Streamgraph': [
+ { to: 'Area Chart', label: 'Area' },
+ { to: 'Line Chart', label: 'Line' },
+ ],
+
+ // ── Two-measure relationship — M₁ × M₂ (§4.3) ──────────────────────────
+ 'Scatter Plot': [
+ {
+ to: 'Strip Plot',
+ label: 'Jitter',
+ route: { from: 'series', to: 'x', mode: 'swap', spill: 'color' },
+ },
+ // Add a fitted trend layer over the same cloud — only a clean
+ // two-measure scatter (both axes quantitative, no size bubble).
+ { to: 'Regression', label: 'Trend', requireBiaxialMeasure: true, requireNoSize: true },
+ ],
+ 'Regression': [
+ { to: 'Scatter Plot', label: 'Scatter' },
+ ],
+ 'Strip Plot': [
+ {
+ to: 'Scatter Plot',
+ label: 'Scatter',
+ route: { from: 'color', to: 'x', mode: 'swap', spill: 'color' },
+ },
+ // A strip plot is a per-category distribution: box (summary) + violin
+ // (density) are the same {x:category, y:measure} layout, no route.
+ { to: 'Boxplot', label: 'Box' },
+ { to: 'Violin Plot', label: 'Violin' },
+ ],
+
+ // ── Univariate distribution — M (§4.4) ─────────────────────────────────
+ 'Histogram': [
+ { to: 'Density Plot', label: 'Density' },
+ { to: 'ECDF Plot', label: 'ECDF' },
+ ],
+ 'Density Plot': [
+ { to: 'Histogram', label: 'Histogram' },
+ { to: 'ECDF Plot', label: 'ECDF' },
+ ],
+ 'ECDF Plot': [
+ { to: 'Histogram', label: 'Histogram' },
+ { to: 'Density Plot', label: 'Density' },
+ ],
+ 'Boxplot': [
+ { to: 'Violin Plot', label: 'Violin' },
+ { to: 'Strip Plot', label: 'Strip' },
+ ],
+ 'Violin Plot': [
+ { to: 'Boxplot', label: 'Box' },
+ { to: 'Strip Plot', label: 'Strip' },
+ ],
+};
+
+/**
+ * Look up the candidate θ transitions for a chart type (by display name).
+ * Returns an empty array when the chart declares none.
+ */
+export function getChartTransitions(chart: string | undefined): PivotTransition[] {
+ if (!chart) return [];
+ return CHART_TRANSITIONS[chart] ?? [];
+}
diff --git a/packages/flint-js/src/core/chart-type-recommendation.ts b/packages/flint-js/src/core/chart-type-recommendation.ts
new file mode 100644
index 00000000..858639a2
--- /dev/null
+++ b/packages/flint-js/src/core/chart-type-recommendation.ts
@@ -0,0 +1,373 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * =============================================================================
+ * DATA-DRIVEN CHART *TYPE* RECOMMENDATION
+ * =============================================================================
+ *
+ * `recommendChannels` / `getRecommendation` answer "which fields go on which
+ * channels **for a chart type I already picked**". They do not answer the step
+ * that sits *in front* of them: **which chart type should I use at all?**
+ *
+ * This module fills that gap. Given raw rows + semantic-type annotations it
+ * builds a deterministic {@link DataProfile} (how many measures / temporals /
+ * categoricals / geographic fields the table has, and their cardinalities) and
+ * scores a ranked list of candidate chart types.
+ *
+ * It reuses the same semantic-type classification the encoders use
+ * (`isMeasureType`, `isTimeSeriesType`, `isGeoType`, …) so the two stages agree
+ * on what a field *is*; it only adds the type-selection heuristic on top.
+ *
+ * Typical use (e.g. Data Formulator surfacing suggestions without a model call):
+ *
+ * ```ts
+ * const types = recommendChartTypes(rows, semanticTypes); // ["Choropleth", "Line Chart", …]
+ * const encodings = vlRecommendEncodings(types[0], rows, semanticTypes);
+ * ```
+ *
+ * Backend wrappers (`vlRecommendChartTypes`, …) pass `supportedTypes` so only
+ * chart types that backend can actually render are returned, and pair it with
+ * their `…RecommendEncodings` to emit type + channels in one step
+ * (`…RecommendCharts`).
+ *
+ * =============================================================================
+ */
+
+import {
+ isMeasureType,
+ isTimeSeriesType,
+ isCategoricalType,
+ isOrdinalType,
+ isGeoCoordinateType,
+ isGeoLocationString,
+ isNonMeasureNumeric,
+} from './semantic-types';
+import { buildTableView, nameMatches, type InternalTableView } from './recommendation';
+
+// ── Field roles ─────────────────────────────────────────────────────────
+
+/**
+ * The role a field plays when choosing a chart type. Mutually exclusive:
+ * every field is classified into exactly one role (the most specific one).
+ */
+export type ChartFieldRole =
+ | 'measure' // true quantitative measure (aggregatable)
+ | 'temporal' // date / time-granule — a time axis
+ | 'categorical' // nominal category / entity
+ | 'ordinal' // ordered discrete (Rank, Range, Score, …)
+ | 'geoPlace' // named place: Country, State, City, Region …
+ | 'latitude' // geographic latitude coordinate
+ | 'longitude' // geographic longitude coordinate
+ | 'identifier' // row id / index — not useful as a category or measure
+ | 'other'; // unclassified
+
+/** A single field annotated with its data/semantic type, cardinality, and role. */
+export interface ProfiledField {
+ name: string;
+ /** Vega-Lite-style vis category: 'quantitative' | 'temporal' | 'nominal' | 'ordinal'. */
+ type: string;
+ /** Semantic type annotation (e.g. "Country", "Amount"), or '' if none. */
+ semanticType: string;
+ /** Number of distinct non-null values. */
+ cardinality: number;
+ role: ChartFieldRole;
+}
+
+/**
+ * A deterministic summary of a table's shape, used to rank chart types.
+ * The bucketed arrays are views over {@link fields} grouped by role, plus
+ * `dimensions` (all category-axis-capable fields: categorical ∪ ordinal ∪
+ * geoPlace ∪ temporal).
+ */
+export interface DataProfile {
+ fields: ProfiledField[];
+ measures: ProfiledField[];
+ temporals: ProfiledField[];
+ categoricals: ProfiledField[];
+ ordinals: ProfiledField[];
+ geoPlaces: ProfiledField[];
+ latitudes: ProfiledField[];
+ longitudes: ProfiledField[];
+ identifiers: ProfiledField[];
+ /** Fields usable on a category/discrete axis (categorical ∪ ordinal ∪ geoPlace ∪ temporal). */
+ dimensions: ProfiledField[];
+ rowCount: number;
+}
+
+// Identifier name patterns — strict (exact or `_suffix`) to avoid false hits
+// like "grid" / "valid" / "android". `rank` is intentionally excluded so a
+// Rank field classifies as ordinal (a usable axis) rather than an identifier.
+const ID_NAME_PATTERNS = ['id', 'index', 'idx', 'row', 'order', 'position', 'pos'];
+
+function looksLikeIdentifier(name: string): boolean {
+ const lower = name.toLowerCase();
+ return ID_NAME_PATTERNS.some(p => lower === p || lower.endsWith('_' + p));
+}
+
+/**
+ * Classify one field into a single {@link ChartFieldRole}. The order of checks
+ * is significant: the most *specific* interpretation wins (geo coordinate →
+ * geo place → temporal → identifier → measure → ordinal → categorical).
+ */
+function classifyRole(name: string, type: string, semanticType: string): ChartFieldRole {
+ const st = semanticType;
+ // Geographic coordinates (Latitude / Longitude): numeric but not measures.
+ if (isGeoCoordinateType(st)) {
+ if (st === 'Latitude' || nameMatches(name, ['latitude', 'lat'])) return 'latitude';
+ if (st === 'Longitude' || nameMatches(name, ['longitude', 'lon', 'lng', 'long'])) return 'longitude';
+ return 'other';
+ }
+ // Named geographic places (Country / State / City / Region / …).
+ if (isGeoLocationString(st)) return 'geoPlace';
+ // Time axis.
+ if (type === 'temporal' || isTimeSeriesType(st)) return 'temporal';
+ // Row identifiers — demoted so they never become a category or measure.
+ if (st === 'ID' || looksLikeIdentifier(name)) return 'identifier';
+ // True quantitative measure (mirrors isQuantitativeField in the encoders).
+ if (type === 'quantitative' && !isNonMeasureNumeric(st) && (isMeasureType(st) || st === '')) {
+ return 'measure';
+ }
+ // Ordered discrete (Rank, Range, Score-as-ordinal, Direction, …).
+ if (isOrdinalType(st)) return 'ordinal';
+ // Plain category.
+ if (type === 'nominal' || isCategoricalType(st)) return 'categorical';
+ return 'other';
+}
+
+/**
+ * Build a {@link DataProfile} from raw rows + semantic-type annotations.
+ * Deterministic — no random sampling; classification is per-field and
+ * order-independent.
+ *
+ * @param data Array of row objects.
+ * @param semanticTypes Field → semantic-type map (e.g. `{ Entity: "Country" }`).
+ */
+export function profileData(data: any[], semanticTypes: Record): DataProfile {
+ const tv: InternalTableView = buildTableView(data, semanticTypes);
+ const fields: ProfiledField[] = tv.names.map(name => ({
+ name,
+ type: tv.fieldType[name] ?? 'nominal',
+ semanticType: tv.fieldSemanticType[name] ?? '',
+ cardinality: tv.fieldLevels[name]?.length ?? 0,
+ role: classifyRole(name, tv.fieldType[name] ?? 'nominal', tv.fieldSemanticType[name] ?? ''),
+ }));
+
+ const by = (r: ChartFieldRole) => fields.filter(f => f.role === r);
+ const categoricals = by('categorical');
+ const ordinals = by('ordinal');
+ const geoPlaces = by('geoPlace');
+ const temporals = by('temporal');
+
+ return {
+ fields,
+ measures: by('measure'),
+ temporals,
+ categoricals,
+ ordinals,
+ geoPlaces,
+ latitudes: by('latitude'),
+ longitudes: by('longitude'),
+ identifiers: by('identifier'),
+ dimensions: [...categoricals, ...ordinals, ...geoPlaces, ...temporals],
+ rowCount: tv.rows.length,
+ };
+}
+
+// ── Scoring ─────────────────────────────────────────────────────────────
+
+/** A ranked chart-type candidate with its score and human-readable reasons. */
+export interface ChartTypeSuggestion {
+ chartType: string;
+ /** Higher is a better fit. Scores are relative; only the ordering is meaningful. */
+ score: number;
+ reasons: string[];
+}
+
+/** Options for {@link recommendChartTypes} / {@link recommendChartTypesDetailed}. */
+export interface RecommendChartTypesOptions {
+ /**
+ * Restrict results to these chart-type names (e.g. a backend's template
+ * catalog). Types not in the list are dropped. Omit for all shared types.
+ */
+ supportedTypes?: string[];
+ /** Cap the number of suggestions returned (after ranking). */
+ max?: number;
+}
+
+/**
+ * A recommended chart type paired with its recommended channel → field
+ * encodings. Returned by the backend one-step recommenders
+ * (`vlRecommendCharts`, …) that chain type selection with encoding selection.
+ */
+export interface RecommendedChart {
+ chartType: string;
+ encodings: Record;
+}
+
+// Cardinality thresholds for readability gates.
+const LOW_CARD_SERIES = 12; // a legend/series stays readable up to ~12 colors
+const LOW_CARD_AXIS = 25; // a discrete heatmap axis stays readable up to ~25 bands
+const PIE_MAX_SLICES = 8; // a pie is legible only with a handful of slices
+
+/** Semantic types the choropleth base maps can actually render (see map.ts). */
+const CHOROPLETH_SEMANTIC = new Set(['Country', 'State']);
+const CHOROPLETH_NAME_HINTS = ['country', 'state', 'province', 'nation'];
+
+/**
+ * Score candidate chart types for a data profile. Returns suggestions sorted by
+ * descending score; ties keep a stable, priority-ordered arrangement.
+ *
+ * The rules are curated design heuristics: more *specific* chart types
+ * (maps, time series) outrank generic ones (bar) when the data supports them,
+ * so a table with a geographic place + a measure surfaces "Choropleth" ahead of
+ * "Bar Chart" without a model call.
+ */
+export function rankChartTypes(profile: DataProfile): ChartTypeSuggestion[] {
+ const {
+ measures, temporals, categoricals, ordinals, geoPlaces,
+ latitudes, longitudes, rowCount,
+ } = profile;
+
+ const catLike = [...categoricals, ...ordinals, ...geoPlaces]; // discrete, non-temporal
+ const dims = profile.dimensions;
+ const lowCardCatLike = catLike.filter(f => f.cardinality >= 2 && f.cardinality <= LOW_CARD_SERIES);
+ const lowCardDims = dims.filter(f => f.cardinality >= 2 && f.cardinality <= LOW_CARD_AXIS);
+ const hasMeasure = measures.length >= 1;
+
+ // Preserve insertion order for stable tie-breaking; keep the max score per type.
+ const order: string[] = [];
+ const acc = new Map();
+ const add = (chartType: string, score: number, reason: string) => {
+ const cur = acc.get(chartType);
+ if (!cur) {
+ order.push(chartType);
+ acc.set(chartType, { score, reasons: [reason] });
+ } else {
+ if (score > cur.score) cur.score = score;
+ if (!cur.reasons.includes(reason)) cur.reasons.push(reason);
+ }
+ };
+
+ // ── Geographic (most specific) ──────────────────────────────────────
+ if (latitudes.length >= 1 && longitudes.length >= 1) {
+ add('Map', 96, 'has latitude + longitude coordinates');
+ }
+ const choroGeo = geoPlaces.filter(
+ f => CHOROPLETH_SEMANTIC.has(f.semanticType) || nameMatches(f.name, CHOROPLETH_NAME_HINTS),
+ );
+ if (choroGeo.length >= 1 && hasMeasure) {
+ add('Choropleth', 92, 'has a geographic region field + a measure');
+ }
+
+ // ── Time series ─────────────────────────────────────────────────────
+ if (temporals.length >= 1 && hasMeasure) {
+ add('Line Chart', 88, 'has a time field + a measure (trend over time)');
+ add('Area Chart', 66, 'has a time field + a measure');
+ }
+
+ // ── Correlation (two measures) ──────────────────────────────────────
+ if (measures.length >= 2) {
+ add('Scatter Plot', 84, 'has two or more measures (relationship)');
+ }
+
+ // ── Category + measure (bar) ────────────────────────────────────────
+ if (dims.length >= 1 && hasMeasure) {
+ add('Bar Chart', 80, 'has a category axis + a measure');
+ }
+
+ // ── Single-measure distribution (histogram) ─────────────────────────
+ if (hasMeasure && dims.length === 0) {
+ add('Histogram', 82, 'has a measure with no category (distribution)');
+ }
+
+ // ── Two-category comparison (grouped / stacked / heatmap) ───────────
+ if (catLike.length >= 2 && lowCardCatLike.length >= 1 && hasMeasure) {
+ add('Grouped Bar Chart', 72, 'has two categories + a measure');
+ add('Stacked Bar Chart', 70, 'has two categories + a measure');
+ }
+ if (lowCardDims.length >= 2 && hasMeasure) {
+ add('Heatmap', 74, 'has two discrete fields + a measure (matrix)');
+ }
+
+ // ── Part-to-whole (pie) ─────────────────────────────────────────────
+ if (catLike.length === 1 && temporals.length === 0 && hasMeasure) {
+ const c = catLike[0];
+ const oneRowPerCategory = rowCount <= c.cardinality * 1.5;
+ if (c.cardinality >= 2 && c.cardinality <= PIE_MAX_SLICES && oneRowPerCategory) {
+ add('Pie Chart', 64, 'a few categories that sum to a whole');
+ }
+ }
+
+ // ── Distribution across groups (box / strip) ────────────────────────
+ if (catLike.length >= 1 && hasMeasure && temporals.length === 0) {
+ const smallest = catLike.reduce((a, b) => (b.cardinality < a.cardinality ? b : a));
+ if (smallest.cardinality >= 1 && rowCount >= smallest.cardinality * 2) {
+ add('Boxplot', 58, 'multiple measure values per group (spread)');
+ add('Strip Plot', 52, 'multiple measure values per group');
+ }
+ }
+
+ // ── Count-only fallbacks (dimensions but no measure) ────────────────
+ if (!hasMeasure && dims.length >= 1) {
+ add('Bar Chart', 60, 'categories without a measure (counts)');
+ if (lowCardDims.length >= 2) add('Heatmap', 55, 'two discrete fields (cross-tab counts)');
+ }
+
+ // ── Last-resort fallback ────────────────────────────────────────────
+ if (order.length === 0) {
+ if (measures.length >= 2) add('Scatter Plot', 20, 'fallback for numeric data');
+ else add('Bar Chart', 15, 'fallback');
+ }
+
+ return order
+ .map(chartType => ({ chartType, score: acc.get(chartType)!.score, reasons: acc.get(chartType)!.reasons }))
+ // Stable sort (ES2019+) — equal scores keep priority insertion order.
+ .sort((a, b) => b.score - a.score);
+}
+
+// ── Public API ──────────────────────────────────────────────────────────
+
+/**
+ * Recommend candidate chart types for a dataset, with scores and reasons.
+ *
+ * @param data Array of row objects.
+ * @param semanticTypes Field → semantic-type map (e.g. `{ Entity: "Country" }`).
+ * @param options Optional `supportedTypes` filter and `max` cap.
+ * @returns Ranked {@link ChartTypeSuggestion}s (best first).
+ */
+export function recommendChartTypesDetailed(
+ data: any[],
+ semanticTypes: Record,
+ options: RecommendChartTypesOptions = {},
+): ChartTypeSuggestion[] {
+ const profile = profileData(data, semanticTypes);
+ let ranked = rankChartTypes(profile);
+ if (options.supportedTypes) {
+ const allowed = new Set(options.supportedTypes);
+ ranked = ranked.filter(s => allowed.has(s.chartType));
+ }
+ if (options.max != null) ranked = ranked.slice(0, options.max);
+ return ranked;
+}
+
+/**
+ * Recommend a ranked list of chart-type names for a dataset (best first).
+ *
+ * A convenience wrapper over {@link recommendChartTypesDetailed} that drops the
+ * scores/reasons. This is the "type-selection step that sits in front of"
+ * `recommendChannels` — call it first, then feed the chosen type to
+ * `…RecommendEncodings` to populate channels.
+ *
+ * @param data Array of row objects.
+ * @param semanticTypes Field → semantic-type map.
+ * @param options Optional `supportedTypes` filter and `max` cap.
+ * @returns Ranked chart-type names (best first).
+ */
+export function recommendChartTypes(
+ data: any[],
+ semanticTypes: Record,
+ options: RecommendChartTypesOptions = {},
+): string[] {
+ return recommendChartTypesDetailed(data, semanticTypes, options).map(s => s.chartType);
+}
diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts
index e9ddd53f..d5ab2c01 100644
--- a/packages/flint-js/src/core/index.ts
+++ b/packages/flint-js/src/core/index.ts
@@ -50,10 +50,19 @@ export {
computePivot,
applyPivot,
makeCartesianPivot,
+ computeArrangeStates,
+ computeChartTypeStates,
+ applyTransform,
+ TRANSFORM_CHART_TYPE_KEY,
+ TRANSFORM_ARRANGE_KEY,
type PivotSurface,
type PivotComputation,
+ type TransformSurface,
} from './pivot';
+// Central chart-type transition registry (the θ graph)
+export { CHART_TRANSITIONS, getChartTransitions } from './chart-transitions';
+
// Reusable encoding-action factories
export { makeSortAction, type SortChoice } from './encoding-actions';
@@ -139,6 +148,20 @@ export {
type SemanticRole,
} from './recommendation';
+// Data-driven chart *type* recommendation
+export {
+ profileData,
+ rankChartTypes,
+ recommendChartTypes,
+ recommendChartTypesDetailed,
+ type ChartFieldRole,
+ type ProfiledField,
+ type DataProfile,
+ type ChartTypeSuggestion,
+ type RecommendChartTypesOptions,
+ type RecommendedChart,
+} from './chart-type-recommendation';
+
// Field semantics
export {
type SemanticAnnotation,
diff --git a/packages/flint-js/src/core/pivot.ts b/packages/flint-js/src/core/pivot.ts
index f223e162..7cf83054 100644
--- a/packages/flint-js/src/core/pivot.ts
+++ b/packages/flint-js/src/core/pivot.ts
@@ -39,6 +39,7 @@
*/
import { ChartEncoding, ChartTemplateDef, PivotDef, PivotTransition } from './types';
+import { getChartTransitions } from './chart-transitions';
/** Resolved pivot surface attached to the assembled spec as `_pivot`. */
export interface PivotSurface {
@@ -54,6 +55,14 @@ export interface PivotSurface {
labels: string[];
}
+/** A redundant identity encoding added by a local Arrange operator. */
+export interface EncodingAugmentation {
+ kind: 'facet-identity';
+ sourceChannel: 'color' | 'group';
+ facetChannel: 'column' | 'row';
+ colorEncoding: ChartEncoding;
+}
+
/** Internal: a fully enumerated pivot for a given encoding map + data. */
export interface PivotComputation {
key: string;
@@ -61,6 +70,7 @@ export interface PivotComputation {
ids: string[];
labels: string[];
statesById: Record>;
+ augmentationById: Record;
/**
* Chart-type override per state id, set only for chart-type *transition*
* states (§4.6). Absent/undefined entries render with the authored template.
@@ -134,6 +144,37 @@ function orderPair(a: string, b: string): [string, string] {
return (ia <= ib ? [a, b] : [b, a]) as [string, string];
}
+/**
+ * Human-friendly channel names for the arrange labels shown in the UI (the
+ * dropdown/switcher). Keeps the stored *ids* untouched — only the display text.
+ */
+const CHANNEL_DISPLAY: Record = {
+ x: 'X',
+ y: 'Y',
+ color: 'Color',
+ size: 'Size',
+ group: 'Groups',
+ column: 'Columns',
+ row: 'Rows',
+ detail: 'Detail',
+ opacity: 'Opacity',
+};
+function chDisplay(ch: string): string {
+ return CHANNEL_DISPLAY[ch] ?? ch.charAt(0).toUpperCase() + ch.slice(1);
+}
+
+/** The set of channels whose bound field differs between two encodings. */
+function changedChannels(
+ a: Record,
+ b: Record,
+): Set {
+ const out = new Set();
+ for (const ch of new Set([...Object.keys(a), ...Object.keys(b)])) {
+ if (a[ch]?.field !== b[ch]?.field) out.add(ch);
+ }
+ return out;
+}
+
/**
* Transpose generator (τ): exchange two axis *slots* wholesale (`x↔y`). This is
* the orientation/flip — it carries each channel's full encoding to the other,
@@ -158,7 +199,7 @@ function transposeState(
const next = clone(base);
next[a] = { ...eb };
next[b] = { ...ea };
- return { id: `flip:${a}-${b}`, label: `τ_${a}↔${b}`, enc: next };
+ return { id: `flip:${a}-${b}`, label: `${chDisplay(a)} ⇄ ${chDisplay(b)}`, enc: next };
}
type ChannelProfile = 'measure' | 'category' | 'time';
@@ -209,7 +250,7 @@ function permuteSwapState(
if (!profile || profile !== channelProfile(auxEnc, template)) return null; // different profile
const id = `swap:${a}-${b}`;
- const label = `σ_${a}↔${b}`;
+ const label = `${chDisplay(a)} ⇄ ${chDisplay(b)}`;
if (profile === 'measure') {
// Demoting a measure to an aux channel only reads on position marks; on a
@@ -254,33 +295,29 @@ function routeBudget(target: string, facetBudget: number): number {
return 20; // color legend
}
-/** Operator label for routing the series onto a grouping channel (γ = shift). */
-function routeLabel(target: string): string {
- return `γ_→${target}`;
+/** Operator label for routing the series from its current channel onto another. */
+function routeLabel(from: string, to: string): string {
+ return `${chDisplay(from)} ⇄ ${chDisplay(to)}`;
}
-/** Find the discrete series field and the grouping channel it currently sits on. */
-function findSeries(
+/** Locate a discrete series for chart-type transition routing. */
+function findTransitionSeries(
base: Record,
candidates: string[],
channels: string[],
): { channel: string; enc: ChartEncoding } | null {
- for (const ch of candidates) {
- if (channels.includes(ch) && isDiscrete(base[ch])) {
- return { channel: ch, enc: base[ch]! };
+ for (const channel of candidates) {
+ if (channels.includes(channel) && isDiscrete(base[channel])) {
+ return { channel, enc: base[channel]! };
}
}
return null;
}
/**
- * Series-routing generator (γ): take the chart's single discrete series field and
- * offer every *other* shiftable channel as an alternative placement — color
- * (stacked), group (grouped/dodged), column/row (facets). The candidate domain is
- * the template's declared `shift` list; this is the dimension that unifies
- * stacked/grouped/faceted presentations behind one template. Returns one state
- * per admissible target (present on the template, currently empty, cardinality
- * within budget).
+ * Identity channels augment onto empty facets; facet channels shift normally.
+ * Keeping the sources independent avoids destructive color-to-facet moves and
+ * lets a chart with both color and column offer two distinct row alternatives.
*/
function seriesRoutingStates(
base: Record,
@@ -288,25 +325,141 @@ function seriesRoutingStates(
data: any[],
shiftChannels: string[],
facetBudget: number,
-): { id: string; enc: Record; label: string }[] {
+ preferredFacet?: 'column' | 'row',
+): { id: string; enc: Record; label: string; augmentation?: EncodingAugmentation }[] {
const channels = template.channels ?? [];
- const src = findSeries(base, shiftChannels, channels);
- if (!src) return [];
- const card = distinctCount(data, src.enc.field);
- const out: { id: string; enc: Record; label: string }[] = [];
- for (const target of shiftChannels) {
- if (target === src.channel) continue;
- if (!channels.includes(target)) continue;
- if (base[target]?.field) continue; // occupied — don't clobber
- if (card > routeBudget(target, facetBudget)) continue;
- const next = clone(base);
- delete next[src.channel];
- next[target] = { ...src.enc };
- out.push({ id: `series:${target}`, enc: next, label: routeLabel(target) });
+ const out: { id: string; enc: Record; label: string; augmentation?: EncodingAugmentation }[] = [];
+
+ const identitySource: 'color' | 'group' | undefined = isDiscrete(base.color)
+ ? 'color'
+ : (!base.color?.field && isDiscrete(base.group) ? 'group' : undefined);
+ if (identitySource && shiftChannels.includes(identitySource) && channels.includes(identitySource)) {
+ const identityEncoding = base[identitySource]!;
+ const card = distinctCount(data, identityEncoding.field);
+ const facetTargets = preferredFacet ? [preferredFacet] : ['column', 'row'] as const;
+ for (const target of facetTargets) {
+ if (!shiftChannels.includes(target) || !channels.includes(target)) continue;
+ if (base[target]?.field || card > routeBudget(target, facetBudget)) continue;
+ const next = clone(base);
+ delete next[identitySource];
+ next[target] = { ...identityEncoding };
+ out.push({
+ id: `augment:${target}`,
+ enc: next,
+ label: `Color + ${chDisplay(target)}`,
+ augmentation: {
+ kind: 'facet-identity',
+ sourceChannel: identitySource,
+ facetChannel: target,
+ colorEncoding: { ...identityEncoding },
+ },
+ });
+ }
+ }
+
+ const facetSource = (['column', 'row'] as const).find(channel =>
+ shiftChannels.includes(channel) && channels.includes(channel) && isDiscrete(base[channel]),
+ );
+ if (facetSource) {
+ const facetEncoding = base[facetSource]!;
+ const card = distinctCount(data, facetEncoding.field);
+ for (const target of shiftChannels) {
+ if (target === facetSource || target === 'group') continue;
+ if ((target === 'column' || target === 'row') && preferredFacet && target !== preferredFacet) continue;
+ if (!channels.includes(target) || base[target]?.field) continue;
+ if (card > routeBudget(target, facetBudget)) continue;
+ const next = clone(base);
+ delete next[facetSource];
+ next[target] = { ...facetEncoding };
+ out.push({ id: `series:${target}`, enc: next, label: routeLabel(facetSource, target) });
+ }
}
return out;
}
+/** Preferred small-multiple direction for the compact dynamic Arrange surface. */
+function preferredFacetTarget(base: Record): 'column' | 'row' {
+ const domain = domainAxisEnc(base);
+ return domain === base.y ? 'row' : 'column';
+}
+
+/** Facet channel newly targeted by a composed transformation, if any. */
+function changedFacetTarget(
+ authored: Record,
+ transformed: Record,
+): 'column' | 'row' | undefined {
+ for (const channel of ['column', 'row'] as const) {
+ if (transformed[channel]?.field && transformed[channel]?.field !== authored[channel]?.field) {
+ return channel;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * The *domain* position axis encoding — the non-measure x/y (a category/time
+ * axis). Prefers whichever position channel is not a measure.
+ */
+function domainAxisEnc(base: Record): ChartEncoding | undefined {
+ if (base.x?.field && !isMeasure(base.x)) return base.x;
+ if (base.y?.field && !isMeasure(base.y)) return base.y;
+ return undefined;
+}
+
+/** The *measure* position axis encoding — the quantitative/aggregated x/y. */
+function measureAxisEnc(base: Record): ChartEncoding | undefined {
+ if (base.x?.field && isMeasure(base.x)) return base.x;
+ if (base.y?.field && isMeasure(base.y)) return base.y;
+ return undefined;
+}
+
+/**
+ * Evaluate a transition's declarative *data-characteristic* gates against the
+ * authored encoding + data (design-docs/chart-transform-two-axes.md §4.9.3).
+ * These are the "not all mappings make sense" guards: a candidate edge is only
+ * offered when the shared fields actually support the sibling's reading.
+ */
+function transitionGatesPass(
+ base: Record,
+ data: any[],
+ t: PivotTransition,
+): boolean {
+ if (t.requireOrderedAxis) {
+ const domain = domainAxisEnc(base);
+ // Ordered = temporal or ordinal; plain nominal never qualifies.
+ if (!domain || !(domain.type === 'temporal' || domain.type === 'ordinal')) return false;
+ }
+ if (t.requireNonNegative) {
+ const measure = measureAxisEnc(base);
+ if (measure?.field) {
+ for (const row of data) {
+ const v = row?.[measure.field];
+ if (typeof v === 'number' && v < 0) return false;
+ }
+ }
+ }
+ if (t.maxCategoryCardinality != null) {
+ const domain = domainAxisEnc(base);
+ if (domain?.field && distinctCount(data, domain.field) > t.maxCategoryCardinality) return false;
+ }
+ if (t.requireNoSeries) {
+ for (const ch of GROUPING_CHANNELS) {
+ if (isDiscrete(base[ch])) return false;
+ }
+ }
+ if (t.requireSeries) {
+ const seriesChannels = ['color', 'group', 'detail', 'column', 'row'];
+ if (!seriesChannels.some((ch) => isDiscrete(base[ch]))) return false;
+ }
+ if (t.requireBiaxialMeasure) {
+ if (!isMeasure(base.x) || !isMeasure(base.y)) return false;
+ }
+ if (t.requireNoSize) {
+ if (base.size?.field) return false;
+ }
+ return true;
+}
+
/**
* Build the encoding map for a chart-type *transition* (§4.6). A transition
* re-views the same data as a sibling chart type, optionally re-routing one
@@ -321,13 +474,14 @@ function transitionState(
template: ChartTemplateDef,
t: PivotTransition,
): { enc: Record; chartType: string; label: string } | null {
+ if (!transitionGatesPass(base, data, t)) return null;
const enc = clone(base);
const route = t.route;
if (route) {
// Resolve the source channel. `'series'` finds the discrete grouping
// field wherever it sits (color/column/row); a literal name is used as-is.
const fromCh = route.from === 'series'
- ? findSeries(base, GROUPING_CHANNELS, template.channels ?? [])?.channel
+ ? findTransitionSeries(base, GROUPING_CHANNELS, template.channels ?? [])?.channel
: route.from;
if (!fromCh) return null;
const srcEnc = base[fromCh];
@@ -348,10 +502,30 @@ function transitionState(
if (dstEnc?.field) enc[spillCh] = { ...dstEnc };
else delete enc[spillCh];
} else {
- // move: the target slot must be empty so we don't clobber a field
- if (dstEnc?.field) return null;
- delete enc[fromCh];
- enc[route.to] = { ...srcEnc };
+ // move: bring the source field onto the target channel. If it is
+ // already there (from === to, e.g. `series` resolved to the target),
+ // it's a no-op; otherwise the target must be empty so we don't clobber.
+ if (fromCh !== route.to) {
+ if (dstEnc?.field) return null;
+ delete enc[fromCh];
+ enc[route.to] = { ...srcEnc };
+ }
+ }
+ }
+ // Re-orient the domain axis for the sibling if requested (bar → line/area):
+ // a horizontal bar carries the ordered/temporal domain on `y`, but a line
+ // pins it to the horizontal — swap x/y wholesale so we never render a
+ // vertical line chart.
+ if (t.orientDomainAxis) {
+ const target = t.orientDomainAxis;
+ const other = target === 'x' ? 'y' : 'x';
+ const domainOnOther = !!enc[other]?.field && !isMeasure(enc[other]);
+ const targetFreeForDomain = !enc[target]?.field || isMeasure(enc[target]);
+ if (domainOnOther && targetFreeForDomain) {
+ const a = enc[target];
+ const b = enc[other];
+ if (b) enc[target] = { ...b }; else delete enc[target];
+ if (a) enc[other] = { ...a }; else delete enc[other];
}
}
return { enc, chartType: t.to, label: t.label };
@@ -368,6 +542,7 @@ interface PivotStep {
label: string;
enc: Record;
chartType?: string;
+ augmentation?: EncodingAugmentation;
}
/**
@@ -382,12 +557,16 @@ function pivotSteps(
template: ChartTemplateDef,
enc: Record,
data: any[],
+ opts?: { local?: boolean; transitions?: boolean; preferFacetTarget?: boolean },
+ resolveTemplate?: (chartType: string) => ChartTemplateDef | undefined,
): PivotStep[] {
const def = template.pivot;
if (!def) return [];
+ const includeLocal = opts?.local !== false;
+ const includeTransitions = opts?.transitions !== false;
const steps: PivotStep[] = [];
// τ: each declared axis-slot pair contributes its wholesale flip (orientation).
- for (const pair of def.transpose ?? []) {
+ if (includeLocal) for (const pair of def.transpose ?? []) {
if (pair.length !== 2) continue;
const s = transposeState(enc, template, [pair[0], pair[1]]);
if (s) steps.push({ id: s.id, label: s.label, enc: s.enc });
@@ -395,7 +574,7 @@ function pivotSteps(
// σ: each permutable block contributes its within-block axis↔aux field swaps as
// candidate one-step moves; the orbit BFS composes them to close the block's
// symmetric group. Profile-mismatched pairs return null and are dropped.
- for (const block of def.permute ?? []) {
+ if (includeLocal) for (const block of def.permute ?? []) {
for (let i = 0; i < block.length; i++) {
for (let j = i + 1; j < block.length; j++) {
const s = permuteSwapState(enc, template, [block[i], block[j]]);
@@ -403,13 +582,20 @@ function pivotSteps(
}
}
}
- if (def.shift && def.shift.length) {
- for (const s of seriesRoutingStates(enc, template, data, def.shift, def.facetBudget ?? 12)) {
- steps.push({ id: s.id, label: s.label, enc: s.enc });
+ if (includeLocal && def.shift && def.shift.length) {
+ const preferredFacet = opts?.preferFacetTarget ? preferredFacetTarget(enc) : undefined;
+ for (const s of seriesRoutingStates(enc, template, data, def.shift, def.facetBudget ?? 12, preferredFacet)) {
+ steps.push({ id: s.id, label: s.label, enc: s.enc, augmentation: s.augmentation });
}
}
- if (def.transitions) {
- for (const t of def.transitions) {
+ // θ: chart-type transitions are sourced from the CENTRAL registry (keyed by
+ // the template's chart name), not the template itself — a template no longer
+ // declares what it can turn into. A candidate edge is emitted only when its
+ // target template exists in the active backend (via `resolveTemplate`, when
+ // supplied); the per-edge data gates run inside transitionState.
+ if (includeTransitions) {
+ for (const t of getChartTransitions(template.chart)) {
+ if (resolveTemplate && !resolveTemplate(t.to)) continue; // backend can't render it
const st = transitionState(enc, data, template, t);
// Operator notation: θ = chart-type transition, subscripted by the target view.
if (st) steps.push({ id: `type:${t.to}`, label: `θ_→${t.label.toLowerCase()}`, enc: st.enc, chartType: st.chartType });
@@ -477,17 +663,22 @@ export function computePivot(
base: Record,
data: any[],
resolveTemplate?: (chartType: string) => ChartTemplateDef | undefined,
+ opts?: { includeTransitions?: boolean; key?: string; label?: string; preferFacetTarget?: boolean },
): PivotComputation | null {
const def = template.pivot;
if (!def) return null;
- const key = def.key ?? 'pivot';
- const label = def.label ?? 'View';
+ const key = opts?.key ?? def.key ?? 'pivot';
+ const label = opts?.label ?? def.label ?? 'View';
+ const includeTransitions = opts?.includeTransitions !== false;
const ids: string[] = ['default'];
const labels: string[] = ['Default'];
const statesById: Record> = {
default: clone(base),
};
+ const augmentationById: Record = {
+ default: undefined,
+ };
const chartTypeById: Record = {
default: undefined,
};
@@ -498,10 +689,11 @@ export function computePivot(
enc: Record;
chartType: string | undefined;
template: ChartTemplateDef;
+ augmentation: EncodingAugmentation | undefined;
}
const seen = new Set([encodingKey(base, undefined)]);
- const queue: OrbitNode[] = [{ id: 'default', label: 'Default', enc: clone(base), chartType: undefined, template }];
+ const queue: OrbitNode[] = [{ id: 'default', label: 'Default', enc: clone(base), chartType: undefined, template, augmentation: undefined }];
// The authored chart type *is* home: a θ path that lands back on it (e.g.
// Stacked → Grouped → Stacked) is not a new view, so we normalize its
// effective chartType to `undefined`. This lets the stabilizer dedup fold
@@ -510,7 +702,11 @@ export function computePivot(
while (queue.length > 0 && ids.length < MAX_PIVOT_STATES) {
const cur = queue.shift()!;
- for (const step of pivotSteps(cur.template, cur.enc, data)) {
+ for (const step of pivotSteps(cur.template, cur.enc, data, {
+ local: true,
+ transitions: includeTransitions,
+ preferFacetTarget: opts?.preferFacetTarget,
+ }, resolveTemplate)) {
// A θ step switches the effective chart type (and thus the template
// whose generators apply next); σ/γ steps stay on the current one.
let nextChartType = step.chartType ?? cur.chartType;
@@ -525,21 +721,48 @@ export function computePivot(
? (resolved ?? { ...cur.template, pivot: undefined })
: cur.template;
if (!isRenderableState(nextTemplate, step.enc)) continue; // avoid invalid combos
+ if (opts?.preferFacetTarget) {
+ const facetTarget = changedFacetTarget(base, step.enc);
+ if (facetTarget && facetTarget !== preferredFacetTarget(step.enc)) continue;
+ }
+ // Drop OVERLAPPING arrangement compositions. Composing two generators
+ // that touch a shared channel yields a confusing 3-cycle (flip X⇄Y then
+ // swap Y⇄Color rotates all three axes, not a clean pairwise swap). We
+ // only compose steps whose moved channels are DISJOINT from what the
+ // path already moved — so every state is a single generator or an
+ // INDEPENDENT combo. The generators themselves are unchanged (the
+ // overlapping states still exist in theory), we just don't enumerate them.
+ if (step.chartType === undefined && cur.id !== 'default') {
+ const already = changedChannels(base, cur.enc);
+ const now = changedChannels(cur.enc, step.enc);
+ let overlaps = false;
+ for (const ch of now) {
+ if (already.has(ch)) { overlaps = true; break; }
+ }
+ if (overlaps) continue;
+ }
const fp = encodingKey(step.enc, nextChartType);
if (seen.has(fp)) continue; // dedup (stabilizer)
seen.add(fp);
const id = cur.id === 'default' ? step.id : `${cur.id}|${step.id}`;
+ // Compositions are now always DISJOINT (see the overlap guard above),
+ // so a plain operator join reads cleanly with no double-counted channel
+ // (e.g. `X ⇄ Y · Color ⇄ Columns`). θ states join the same way.
const stepLabel = cur.id === 'default' ? step.label : `${cur.label} · ${step.label}`;
ids.push(id);
labels.push(stepLabel);
statesById[id] = step.enc;
chartTypeById[id] = nextChartType;
- queue.push({ id, label: stepLabel, enc: step.enc, chartType: nextChartType, template: nextTemplate });
+ const augmentation = step.chartType
+ ? undefined
+ : (step.augmentation ?? cur.augmentation);
+ augmentationById[id] = augmentation;
+ queue.push({ id, label: stepLabel, enc: step.enc, chartType: nextChartType, template: nextTemplate, augmentation });
if (ids.length >= MAX_PIVOT_STATES) break;
}
}
- return { key, label, ids, labels, statesById, chartTypeById };
+ return { key, label, ids, labels, statesById, augmentationById, chartTypeById };
}
/**
@@ -553,16 +776,17 @@ export function applyPivot(
data: any[],
chartProperties: Record | undefined,
resolveTemplate?: (chartType: string) => ChartTemplateDef | undefined,
-): { encodings: Record; chartType: string | undefined; surface: PivotSurface | undefined } {
+): { encodings: Record; augmentation: EncodingAugmentation | undefined; chartType: string | undefined; surface: PivotSurface | undefined } {
const comp = computePivot(template, base, data, resolveTemplate);
if (!comp || comp.ids.length <= 1) {
- return { encodings: base, chartType: undefined, surface: undefined };
+ return { encodings: base, augmentation: undefined, chartType: undefined, surface: undefined };
}
const stored = chartProperties?.[comp.key];
const id = typeof stored === 'string' && comp.ids.includes(stored) ? stored : comp.ids[0];
const index = comp.ids.indexOf(id);
return {
encodings: comp.statesById[id],
+ augmentation: comp.augmentationById[id],
chartType: comp.chartTypeById[id],
surface: {
key: comp.key,
@@ -574,3 +798,195 @@ export function applyPivot(
},
};
}
+
+// ─── Factored two-control model (design-docs/chart-transform-two-axes.md) ─────
+//
+// The single composed orbit above is re-exposed as TWO independent controls:
+// - Control B (chart type, θ): a one-hop transition menu enumerated from the
+// object's *identity* — no composition, independent of Control A.
+// - Control A (arrange, τ/σ/γ): the local group of the *effective* chart type
+// (authored, or the θ-selected sibling), BFS-composed and deduped.
+// The two are stored as separate override keys (`chartType`, `arrange`). A θ
+// switch resets `arrange` to identity and rebuilds Control A on the new object.
+
+/** Both control surfaces resolved for the current input. */
+export interface TransformSurface {
+ /** Control B — chart-type transitions (dropdown). Absent when no siblings. */
+ chartType?: PivotSurface;
+ /** Control A — local rearrangement group (stepper). Absent when trivial. */
+ arrange?: PivotSurface;
+}
+
+/** Override keys the two controls read/write under `chartProperties`. */
+export const TRANSFORM_CHART_TYPE_KEY = 'chartType';
+export const TRANSFORM_ARRANGE_KEY = 'arrange';
+
+/** Build a PivotSurface for a chosen state id within a computation. */
+function buildSurface(comp: PivotComputation, id: string): PivotSurface {
+ const index = Math.max(0, comp.ids.indexOf(id));
+ return {
+ key: comp.key,
+ label: comp.label,
+ length: comp.ids.length,
+ index,
+ ids: comp.ids,
+ labels: comp.labels,
+ };
+}
+
+/**
+ * Control A enumeration: the local rearrangement group (τ/σ/γ only, no θ) of a
+ * template, BFS-composed and deduped exactly like {@link computePivot} but with
+ * chart-type transitions excluded. Runs on the *effective* object's identity
+ * encoding (post-θ), so it offers exactly the moves that make sense there.
+ */
+export function computeArrangeStates(
+ template: ChartTemplateDef,
+ base: Record,
+ data: any[],
+): PivotComputation | null {
+ return computePivot(template, base, data, undefined, {
+ includeTransitions: false,
+ key: TRANSFORM_ARRANGE_KEY,
+ label: 'Arrange',
+ preferFacetTarget: true,
+ });
+}
+
+/**
+ * Control B enumeration: the one-hop chart-type transitions (θ only) of a
+ * template, enumerated from the object's *identity* encoding — no composition,
+ * no τ/σ/γ. Each state re-routes fields for a sibling chart type and carries a
+ * `chartType` override the compiler re-dispatches on. State 0 is the authored
+ * type (`default`, no override); labels are the sibling chart-type display
+ * names so the dropdown reads "Bar Chart · Line Chart · …". Returns `null` when
+ * the template declares no transitions.
+ */
+export function computeChartTypeStates(
+ template: ChartTemplateDef,
+ base: Record,
+ data: any[],
+ resolveTemplate?: (chartType: string) => ChartTemplateDef | undefined,
+): PivotComputation | null {
+ // Transitions come from the central registry (keyed by chart name), NOT from
+ // the template's pivot def — so a template with no local τ/σ/γ group (e.g. a
+ // Pyramid) can still offer chart-type siblings.
+ const transitions = getChartTransitions(template.chart);
+ if (transitions.length === 0) return null;
+
+ const ids: string[] = ['default'];
+ const labels: string[] = [template.chart];
+ const statesById: Record> = { default: clone(base) };
+ const augmentationById: Record = { default: undefined };
+ const chartTypeById: Record = { default: undefined };
+ const seenChartTypes = new Set([template.chart]);
+
+ for (const t of transitions) {
+ // Backend gate: skip a target the active backend can't render.
+ if (resolveTemplate && !resolveTemplate(t.to)) continue;
+ const st = transitionState(base, data, template, t);
+ if (!st) continue;
+ // One sibling per target chart type; skip a hop back to the authored type.
+ if (seenChartTypes.has(st.chartType)) continue;
+ seenChartTypes.add(st.chartType);
+ const id = `type:${t.to}`;
+ ids.push(id);
+ labels.push(st.chartType);
+ statesById[id] = st.enc;
+ chartTypeById[id] = st.chartType;
+ }
+
+ if (ids.length <= 1) return null;
+ return { key: TRANSFORM_CHART_TYPE_KEY, label: 'Chart type', ids, labels, statesById, augmentationById, chartTypeById };
+}
+
+/**
+ * Resolve the two transform override ids from `chartProperties`, with a
+ * backward-compatible shim for the legacy single composed `pivot` id: a
+ * `type:*` token routes to the chart-type override and the remaining τ/σ/γ
+ * tokens (before it) route to arrange. Because a θ resets arrange in the new
+ * model, tokens *after* a `type:*` token are dropped (best-effort migration).
+ */
+function resolveTransformOverrides(
+ chartProperties: Record | undefined,
+): { chartTypeId: string | undefined; arrangeId: string | undefined } {
+ let chartTypeId = chartProperties?.[TRANSFORM_CHART_TYPE_KEY];
+ let arrangeId = chartProperties?.[TRANSFORM_ARRANGE_KEY];
+ if (typeof chartTypeId !== 'string') chartTypeId = undefined;
+ if (typeof arrangeId !== 'string') arrangeId = undefined;
+
+ if (chartTypeId === undefined && arrangeId === undefined) {
+ const legacy = chartProperties?.pivot;
+ if (typeof legacy === 'string' && legacy.length > 0 && legacy !== 'default') {
+ const tokens = legacy.split('|');
+ const typeIdx = tokens.findIndex((t) => t.startsWith('type:'));
+ if (typeIdx >= 0) {
+ chartTypeId = tokens[typeIdx];
+ const local = tokens.slice(0, typeIdx);
+ arrangeId = local.length ? local.join('|') : undefined;
+ } else {
+ arrangeId = legacy;
+ }
+ }
+ }
+ return { chartTypeId, arrangeId };
+}
+
+/**
+ * Resolve the active state for the two independent controls and return the
+ * transformed encodings + both surfaces. Order (design §4.10.1): apply the
+ * chart-type transition (Control B) from the authored identity FIRST, re-select
+ * the sibling template, THEN enumerate + apply that object's local arrange group
+ * (Control A). A stale/absent `arrange` id falls back to identity — which is the
+ * reset-on-θ behavior for free (design §4.10.2).
+ */
+export function applyTransform(
+ template: ChartTemplateDef,
+ base: Record,
+ data: any[],
+ chartProperties: Record | undefined,
+ resolveTemplate?: (chartType: string) => ChartTemplateDef | undefined,
+): {
+ encodings: Record;
+ augmentation: EncodingAugmentation | undefined;
+ chartType: string | undefined;
+ surface: TransformSurface;
+} {
+ const { chartTypeId, arrangeId } = resolveTransformOverrides(chartProperties);
+
+ // Control B — chart type (θ), from the authored identity.
+ let effectiveTemplate = template;
+ let effectiveEnc = base;
+ let chartType: string | undefined;
+ let chartTypeSurface: PivotSurface | undefined;
+ const ctComp = computeChartTypeStates(template, base, data, resolveTemplate);
+ if (ctComp && ctComp.ids.length > 1) {
+ const id = chartTypeId && ctComp.ids.includes(chartTypeId) ? chartTypeId : 'default';
+ effectiveEnc = ctComp.statesById[id];
+ chartType = ctComp.chartTypeById[id];
+ if (chartType) {
+ const resolved = resolveTemplate?.(chartType);
+ if (resolved) effectiveTemplate = resolved;
+ }
+ chartTypeSurface = buildSurface(ctComp, id);
+ }
+
+ // Control A — arrange (τ/σ/γ), on the effective object.
+ let encodings = effectiveEnc;
+ let augmentation: EncodingAugmentation | undefined;
+ let arrangeSurface: PivotSurface | undefined;
+ const arrComp = computeArrangeStates(effectiveTemplate, effectiveEnc, data);
+ if (arrComp && arrComp.ids.length > 1) {
+ const id = arrangeId && arrComp.ids.includes(arrangeId) ? arrangeId : arrComp.ids[0];
+ encodings = arrComp.statesById[id];
+ augmentation = arrComp.augmentationById[id];
+ arrangeSurface = buildSurface(arrComp, id);
+ }
+
+ return {
+ encodings,
+ augmentation,
+ chartType,
+ surface: { chartType: chartTypeSurface, arrange: arrangeSurface },
+ };
+}
diff --git a/packages/flint-js/src/core/recommendation.ts b/packages/flint-js/src/core/recommendation.ts
index 879ba927..e5ef8890 100644
--- a/packages/flint-js/src/core/recommendation.ts
+++ b/packages/flint-js/src/core/recommendation.ts
@@ -710,16 +710,18 @@ export function pick(
}
}
if (candidates.length === 0) return undefined;
- // Prefer fields from the user's existing encodings when available
+ // Deterministic selection: prefer a field the user already uses, otherwise
+ // the first candidate in table order. Stable, so a given dataset always
+ // yields the same suggestion (no random tie-break).
if (tv.preferredFields) {
const preferred = candidates.filter(n => tv.preferredFields!.has(n));
if (preferred.length > 0) {
- const chosen = preferred[Math.floor(Math.random() * preferred.length)];
+ const chosen = preferred[0];
used.add(chosen);
return chosen;
}
}
- const chosen = candidates[Math.floor(Math.random() * candidates.length)];
+ const chosen = candidates[0];
used.add(chosen);
return chosen;
}
@@ -797,6 +799,22 @@ function isValidGroupingField(tv: InternalTableView, xField: string, colorField:
return true;
}
+/**
+ * Deterministic grouping/series selection: choose the lowest-cardinality
+ * candidate (fewest distinct values → the most readable legend), breaking ties
+ * by table order. Replaces an earlier random tie-break so recommendations are
+ * stable and prefer compact color/series encodings.
+ */
+function lowestCardinality(tv: InternalTableView, candidates: string[]): string {
+ let best = candidates[0];
+ let bestCard = tv.fieldLevels[best]?.length ?? Infinity;
+ for (let i = 1; i < candidates.length; i++) {
+ const card = tv.fieldLevels[candidates[i]]?.length ?? Infinity;
+ if (card < bestCard) { best = candidates[i]; bestCard = card; }
+ }
+ return best;
+}
+
export function pickValidGroupingField(
tv: InternalTableView, used: Set, xField: string, maxCard = 20,
): string | undefined {
@@ -812,16 +830,17 @@ export function pickValidGroupingField(
if (isValidGroupingField(tv, xField, name)) candidates.push(name);
}
if (candidates.length === 0) return undefined;
- // Prefer fields from existing encodings
+ // Prefer a field the user already uses; otherwise the lowest-cardinality
+ // valid grouping field (fewest colors). Deterministic — no random pick.
if (tv.preferredFields) {
const preferred = candidates.filter(n => tv.preferredFields!.has(n));
if (preferred.length > 0) {
- const chosen = preferred[Math.floor(Math.random() * preferred.length)];
+ const chosen = lowestCardinality(tv, preferred);
used.add(chosen);
return chosen;
}
}
- const chosen = candidates[Math.floor(Math.random() * candidates.length)];
+ const chosen = lowestCardinality(tv, candidates);
used.add(chosen);
return chosen;
}
@@ -864,16 +883,17 @@ export function pickLineChartColorField(
if (isValidLineSeriesData(tv, xField, name)) candidates.push(name);
}
if (candidates.length === 0) return undefined;
- // Prefer fields from existing encodings
+ // Prefer a field the user already uses; otherwise the lowest-cardinality
+ // valid series field (fewest lines/colors). Deterministic — no random pick.
if (tv.preferredFields) {
const preferred = candidates.filter(n => tv.preferredFields!.has(n));
if (preferred.length > 0) {
- const chosen = preferred[Math.floor(Math.random() * preferred.length)];
+ const chosen = lowestCardinality(tv, preferred);
used.add(chosen);
return chosen;
}
}
- const chosen = candidates[Math.floor(Math.random() * candidates.length)];
+ const chosen = lowestCardinality(tv, candidates);
used.add(chosen);
return chosen;
}
@@ -989,11 +1009,14 @@ export function getRecommendation(chartType: string, tv: InternalTableView): Rec
const xField = pickDiscrete(tv, used);
const yField = pickQuantitative(tv, used);
if (!xField || !yField) return {};
- const colorField = pickValidGroupingField(tv, used, xField, 20);
- if (!colorField) return {};
+ const seriesField = pickValidGroupingField(tv, used, xField, 20);
+ if (!seriesField) return {};
assign('x', xField);
assign('y', yField);
- assign('color', colorField);
+ // A grouped bar dodges by the `group` channel (not `color`) across
+ // all three backends — assigning `color` here would be dropped by
+ // the Vega-Lite template, leaving an ungrouped bar.
+ assign('group', seriesField);
break;
}
diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts
index c2d57b1f..b8f616cf 100644
--- a/packages/flint-js/src/core/types.ts
+++ b/packages/flint-js/src/core/types.ts
@@ -734,6 +734,56 @@ export interface PivotTransition {
requireDiscreteSource?: boolean;
/** Only offer when the routed source field's distinct count is within this budget. */
maxSourceCardinality?: number;
+ /**
+ * Only offer when the *domain* position axis (the non-measure x/y) carries an
+ * ordered type — `temporal` or `ordinal`, never plain `nominal`. This is the
+ * hard gate for bar → line/area: you may not connect unordered categories.
+ * Per the design decision, order is taken from the resolved encoding type
+ * (derived from the semantic type), NOT inferred from sort state.
+ */
+ requireOrderedAxis?: boolean;
+ /**
+ * Only offer when every value on the *measure* position axis is ≥ 0. The gate
+ * for part-to-whole / filled siblings (pie, area) where a negative magnitude
+ * would misread.
+ */
+ requireNonNegative?: boolean;
+ /**
+ * Only offer when the *domain* axis distinct count is within this budget — the
+ * low-cardinality guard for pie/rose (few slices) and line → bar (few ticks).
+ */
+ maxCategoryCardinality?: number;
+ /**
+ * Only offer when NO discrete series channel (color/group/column/row) is bound
+ * — the single-series guard for a part-to-whole pie/donut.
+ */
+ requireNoSeries?: boolean;
+ /**
+ * Only offer when a discrete series channel (color/group/detail/column/row) IS
+ * bound — the multi-series guard for small-multiple siblings (e.g. Line →
+ * Sparkline needs a series to make one strip per category).
+ */
+ requireSeries?: boolean;
+ /**
+ * Only offer when BOTH position axes (x and y) are measures (quantitative or
+ * aggregated) — the guard for a fitted trend (Scatter → Regression): a
+ * regression line is meaningless over a nominal/category axis.
+ */
+ requireBiaxialMeasure?: boolean;
+ /**
+ * Only offer when the `size` channel is NOT bound — keeps a fitted-trend
+ * sibling (Regression) to a clean 2-variable scatter rather than layering it
+ * over a bubble chart.
+ */
+ requireNoSize?: boolean;
+ /**
+ * After routing, force the *domain* (non-measure) position axis onto this
+ * channel, swapping `x`/`y` wholesale if it currently sits on the other. Used
+ * for bar → line/area: a *horizontal* bar carries the ordered/temporal domain
+ * on `y`, but a line pins time to the horizontal, so the transition must
+ * re-orient to `x` (otherwise you get a nonsensical vertical line chart).
+ */
+ orientDomainAxis?: 'x' | 'y';
}
/**
diff --git a/packages/flint-js/src/echarts/index.ts b/packages/flint-js/src/echarts/index.ts
index 5619a199..501abae9 100644
--- a/packages/flint-js/src/echarts/index.ts
+++ b/packages/flint-js/src/echarts/index.ts
@@ -31,4 +31,4 @@ export {
} from './templates';
// EC recommendation & adaptation
-export { ecAdaptChart, ecRecommendEncodings } from './recommendation';
+export { ecAdaptChart, ecRecommendEncodings, ecRecommendChartTypes, ecRecommendCharts } from './recommendation';
diff --git a/packages/flint-js/src/echarts/recommendation.ts b/packages/flint-js/src/echarts/recommendation.ts
index 2b866186..af28884a 100644
--- a/packages/flint-js/src/echarts/recommendation.ts
+++ b/packages/flint-js/src/echarts/recommendation.ts
@@ -17,7 +17,12 @@ import {
pickDiscrete,
pickLowCardDiscrete,
} from '../core/recommendation';
-import { ecGetTemplateChannels } from './templates';
+import {
+ recommendChartTypes,
+ type RecommendChartTypesOptions,
+ type RecommendedChart,
+} from '../core/chart-type-recommendation';
+import { ecGetTemplateChannels, ecAllTemplateDefs } from './templates';
// ── EC-extended recommendation ──────────────────────────────────────────
@@ -103,3 +108,36 @@ export function ecRecommendEncodings(
}
return result;
}
+
+/** Chart-type names ECharts can render (its template catalog). */
+const EC_SUPPORTED_TYPES = ecAllTemplateDefs.map(d => d.chart);
+
+/**
+ * Recommend a ranked list of ECharts chart types for a dataset, restricted to
+ * chart types ECharts can render. See {@link ecRecommendCharts} for the
+ * one-step variant that also fills channels.
+ */
+export function ecRecommendChartTypes(
+ data: any[],
+ semanticTypes: Record,
+ options: Omit = {},
+): string[] {
+ return recommendChartTypes(data, semanticTypes, { ...options, supportedTypes: EC_SUPPORTED_TYPES });
+}
+
+/**
+ * One-step recommendation: rank ECharts chart types for the data, then populate
+ * each with {@link ecRecommendEncodings}. Suggestions whose required channels
+ * cannot be filled are dropped, so every returned chart is renderable.
+ */
+export function ecRecommendCharts(
+ data: any[],
+ semanticTypes: Record,
+ options: Omit = {},
+): RecommendedChart[] {
+ const { max, ...rest } = options;
+ const charts = ecRecommendChartTypes(data, semanticTypes, rest)
+ .map(chartType => ({ chartType, encodings: ecRecommendEncodings(chartType, data, semanticTypes) }))
+ .filter(s => Object.keys(s.encodings).length > 0);
+ return max != null ? charts.slice(0, max) : charts;
+}
diff --git a/packages/flint-js/src/echarts/templates/area.ts b/packages/flint-js/src/echarts/templates/area.ts
index 4ba168ed..30bcde04 100644
--- a/packages/flint-js/src/echarts/templates/area.ts
+++ b/packages/flint-js/src/echarts/templates/area.ts
@@ -302,7 +302,12 @@ export const ecAreaChartDef: ChartTemplateDef = {
{ value: 'step-after', label: 'Step After' },
],
} as ChartPropertyDef,
- { key: 'opacity', label: 'Opacity', type: 'continuous', min: 0.1, max: 1, step: 0.05, defaultValue: 0.7 } as ChartPropertyDef,
+ {
+ key: 'opacity', label: 'Opacity', type: 'continuous', min: 0.1, max: 1, step: 0.05, defaultValue: 0.7,
+ check: (ctx) => ({
+ applicable: !!ctx.encodings.color?.field && ctx.chartProperties?.stackMode === 'layered',
+ }),
+ } as ChartPropertyDef,
{
key: 'stackMode', label: 'Stack', type: 'discrete', options: [
{ value: undefined, label: 'Stacked (default)' },
diff --git a/packages/flint-js/src/echarts/templates/bar.ts b/packages/flint-js/src/echarts/templates/bar.ts
index 7af583cb..e66dfc13 100644
--- a/packages/flint-js/src/echarts/templates/bar.ts
+++ b/packages/flint-js/src/echarts/templates/bar.ts
@@ -448,7 +448,7 @@ export const ecBarChartDef: ChartTemplateDef = {
pivot: makeCartesianPivot({
transpose: [['x', 'y']],
permute: [['x', 'y', 'color']],
- shift: ['color', 'group', 'column', 'row'],
+ shift: ['color', 'column', 'row'],
}),
};
@@ -663,7 +663,6 @@ export const ecStackedBarChartDef: ChartTemplateDef = {
key: 'stackMode', label: 'Stack', type: 'discrete', options: [
{ value: undefined, label: 'Stacked (default)' },
{ value: 'normalize', label: 'Normalize (100%)' },
- { value: 'layered', label: 'Layered (overlap)' },
],
check: (ctx) => ({ applicable: !!ctx.encodings.color?.field }),
},
@@ -672,15 +671,7 @@ export const ecStackedBarChartDef: ChartTemplateDef = {
transpose: [['x', 'y']],
permute: [['x', 'y', 'color']],
shift: ['color', 'group', 'column', 'row'],
- transitions: [
- {
- to: 'Grouped Bar Chart',
- label: 'Grouped',
- route: { from: 'color', to: 'group', mode: 'move' },
- requireDiscreteSource: true,
- maxSourceCardinality: 12,
- },
- ],
+ // θ (→ Grouped Bar) declared centrally in core/chart-transitions.ts.
}),
};
@@ -988,13 +979,6 @@ export const ecGroupedBarChartDef: ChartTemplateDef = {
transpose: [['x', 'y']],
permute: [['x', 'y', 'color']],
shift: ['color', 'group', 'column', 'row'],
- transitions: [
- {
- to: 'Stacked Bar Chart',
- label: 'Stacked',
- route: { from: 'group', to: 'color', mode: 'move' },
- requireDiscreteSource: true,
- },
- ],
+ // θ (→ Stacked Bar) declared centrally in core/chart-transitions.ts.
}),
};
diff --git a/packages/flint-js/src/echarts/templates/boxplot.ts b/packages/flint-js/src/echarts/templates/boxplot.ts
index 5b5d9525..91e3a438 100644
--- a/packages/flint-js/src/echarts/templates/boxplot.ts
+++ b/packages/flint-js/src/echarts/templates/boxplot.ts
@@ -79,6 +79,76 @@ function findOutliers(values: number[]): number[] {
return values.filter(v => v < lo || v > hi);
}
+function boxplotLaneOffset(bandWidth: number, laneCount: number, laneIndex: number): number {
+ const availableWidth = bandWidth * 0.8 - 2;
+ const boxGap = availableWidth / laneCount * 0.3;
+ const boxWidth = (availableWidth - boxGap * (laneCount - 1)) / laneCount;
+ return boxWidth / 2 - availableWidth / 2 + laneIndex * (boxGap + boxWidth);
+}
+
+function makeOutlierSeries(
+ name: string,
+ data: any[],
+ laneIndex: number,
+ laneCount: number,
+ horizontal: boolean,
+): any {
+ return {
+ name,
+ type: 'custom',
+ coordinateSystem: 'cartesian2d',
+ data,
+ encode: { tooltip: [0, 1] },
+ z: 3,
+ renderItem: (_params: any, api: any) => {
+ const category = Number(api.value(0));
+ const value = Number(api.value(1));
+ const point = horizontal
+ ? api.coord([value, category])
+ : api.coord([category, value]);
+ const size = api.size(horizontal ? [0, 1] : [1, 0]);
+ const bandWidth = Math.abs(horizontal ? size[1] : size[0]);
+ const offset = boxplotLaneOffset(bandWidth, laneCount, laneIndex);
+ return {
+ type: 'circle',
+ shape: {
+ cx: point[0] + (horizontal ? 0 : offset),
+ cy: point[1] + (horizontal ? offset : 0),
+ r: 2,
+ },
+ style: { fill: api.visual('color') },
+ };
+ },
+ };
+}
+
+function makeGroupSeparatorSeries(categoryCount: number, horizontal: boolean): any {
+ return {
+ name: '__groupSeparators',
+ type: 'custom',
+ coordinateSystem: 'cartesian2d',
+ data: Array.from({ length: Math.max(0, categoryCount - 1) }, (_, index) => [index]),
+ silent: true,
+ tooltip: { show: false },
+ z: 0,
+ renderItem: (params: any, api: any) => {
+ const index = Number(api.value(0));
+ const current = horizontal ? api.coord([0, index]) : api.coord([index, 0]);
+ const next = horizontal ? api.coord([0, index + 1]) : api.coord([index + 1, 0]);
+ const boundary = horizontal
+ ? (current[1] + next[1]) / 2
+ : (current[0] + next[0]) / 2;
+ return {
+ type: 'line',
+ shape: horizontal
+ ? { x1: params.coordSys.x, y1: boundary, x2: params.coordSys.x + params.coordSys.width, y2: boundary }
+ : { x1: boundary, y1: params.coordSys.y, x2: boundary, y2: params.coordSys.y + params.coordSys.height },
+ style: { stroke: '#c9ced6', lineWidth: 1, lineDash: [4, 4], opacity: 0.75 },
+ };
+ },
+ };
+}
+
export const ecBoxplotDef: ChartTemplateDef = {
chart: 'Boxplot',
template: { mark: 'boxplot', encoding: {} },
@@ -203,15 +273,15 @@ export const ecBoxplotDef: ChartTemplateDef = {
// value lookup per (cat, color)
const catGroups = groupBy(table, catField);
for (let lane = 0; lane < maxPerBand; lane++) {
- const boxData: ({ value: [number, number, number, number, number]; itemStyle: any } | null)[] = [];
+ const boxData: ({ value: [number, number, number, number, number]; itemStyle: any } | '-')[] = [];
const outlierData: any[] = [];
for (let i = 0; i < categories.length; i++) {
const cat = categories[i];
const g = perBand.get(cat)?.[lane];
- if (g === undefined) { boxData.push(null); continue; }
+ if (g === undefined) { boxData.push('-'); continue; }
const rows = (catGroups.get(cat) || []).filter((r: any) => String(r[colorField] ?? '') === g);
const values = rows.map((r: any) => Number(r[valField])).filter((v: number) => isFinite(v));
- if (!values.length) { boxData.push(null); continue; }
+ if (!values.length) { boxData.push('-'); continue; }
const c = colorFor(g);
boxData.push({ value: fiveNumberSummary(values, whiskerMethod), itemStyle: { color: c, borderColor: c } });
if (showOutliers) {
@@ -220,9 +290,12 @@ export const ecBoxplotDef: ChartTemplateDef = {
}
option.series.push({ name: `__lane${lane}`, type: 'boxplot', data: boxData });
if (outlierData.length > 0) {
- option.series.push({ name: `__lane${lane} (outliers)`, type: 'scatter', data: outlierData, symbolSize: 4 });
+ option.series.push(makeOutlierSeries(
+ `__lane${lane} (outliers)`, outlierData, lane, maxPerBand, isHorizontal,
+ ));
}
}
+ option.series.push(makeGroupSeparatorSeries(categories.length, isHorizontal));
option.legend = { data: globalColors.map((g) => ({ name: g, itemStyle: { color: colorFor(g) } })) };
option._legendTitle = colorField;
} else if (colorIsDiscrete && colorField && dodgeColor) {
@@ -232,7 +305,7 @@ export const ecBoxplotDef: ChartTemplateDef = {
for (let cIdx = 0; cIdx < colorCategories.length; cIdx++) {
const colorName = colorCategories[cIdx];
- const boxData: ([number, number, number, number, number] | null)[] = [];
+ const boxData: ([number, number, number, number, number] | '-')[] = [];
const outlierData: [number, number][] = [];
for (let i = 0; i < categories.length; i++) {
@@ -244,8 +317,9 @@ export const ecBoxplotDef: ChartTemplateDef = {
// Empty (category, color) cells must draw NO box. Pushing a
// five-number summary of [] yields [0,0,0,0,0] — a degenerate
// flat box at 0 in every unoccupied lane (the sparse-dodge
- // zero-box bug). `null` leaves the lane blank.
- boxData.push(values.length ? fiveNumberSummary(values, whiskerMethod) : null);
+ // zero-box bug). ECharts boxplot does not accept `null`
+ // data items; `'-'` is its missing-value sentinel.
+ boxData.push(values.length ? fiveNumberSummary(values, whiskerMethod) : '-');
if (showOutliers) {
for (const o of findOutliers(values)) {
@@ -261,13 +335,9 @@ export const ecBoxplotDef: ChartTemplateDef = {
// itemStyle 由 ecApplyLayoutToSpec 按 colorDecisions 填充
});
if (outlierData.length > 0) {
- option.series.push({
- name: colorName + ' (outliers)',
- type: 'scatter',
- data: outlierData,
- symbolSize: 4,
- // 颜色由 ecApplyLayoutToSpec 按类别与 box 一致分配
- });
+ option.series.push(makeOutlierSeries(
+ colorName + ' (outliers)', outlierData, cIdx, colorCategories.length, isHorizontal,
+ ));
}
}
@@ -298,13 +368,7 @@ export const ecBoxplotDef: ChartTemplateDef = {
// 单系列颜色由 ecApplyLayoutToSpec 使用 cat10[0] 等统一默认
});
if (outlierData.length > 0) {
- option.series.push({
- name: 'Outliers',
- type: 'scatter',
- data: outlierData,
- symbolSize: 4,
- // 离群点颜色由 ecApplyLayoutToSpec 分配
- });
+ option.series.push(makeOutlierSeries('Outliers', outlierData, 0, 1, isHorizontal));
}
}
diff --git a/packages/flint-js/src/echarts/templates/candlestick.ts b/packages/flint-js/src/echarts/templates/candlestick.ts
index 758d8981..dd9dc535 100644
--- a/packages/flint-js/src/echarts/templates/candlestick.ts
+++ b/packages/flint-js/src/echarts/templates/candlestick.ts
@@ -160,10 +160,10 @@ export const ecCandlestickDef: ChartTemplateDef = {
properties: [
{
- key: 'showMA', label: 'Show Moving Avg', type: 'binary', defaultValue: false,
+ key: 'showMA', label: 'Moving average', type: 'binary', defaultValue: false,
} as ChartPropertyDef,
{
- key: 'maWindow', label: 'MA Window', type: 'continuous',
+ key: 'maWindow', label: 'Average window', type: 'continuous',
min: 3, max: 30, step: 1, defaultValue: 5,
} as ChartPropertyDef,
],
diff --git a/packages/flint-js/src/echarts/templates/ecdf.ts b/packages/flint-js/src/echarts/templates/ecdf.ts
index 5814111d..7cc992fe 100644
--- a/packages/flint-js/src/echarts/templates/ecdf.ts
+++ b/packages/flint-js/src/echarts/templates/ecdf.ts
@@ -112,6 +112,6 @@ export const ecEcdfPlotDef: ChartTemplateDef = {
delete spec.encoding;
},
properties: [
- { key: 'showPoints', label: 'Show points', type: 'binary', defaultValue: false } as ChartPropertyDef,
+ { key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false } as ChartPropertyDef,
],
};
diff --git a/packages/flint-js/src/echarts/templates/gantt.ts b/packages/flint-js/src/echarts/templates/gantt.ts
index 74a5876d..2c3b4980 100644
--- a/packages/flint-js/src/echarts/templates/gantt.ts
+++ b/packages/flint-js/src/echarts/templates/gantt.ts
@@ -21,19 +21,16 @@
*/
import { ChartTemplateDef } from '../../core/types';
+import {
+ coerceGanttEndpoint,
+ formatGanttLabel,
+ ganttLabelReservePx,
+ GANTT_PROPERTIES,
+ isGanttTemporal,
+ sortGanttRows,
+} from '../../chart-types/gantt';
import { DEFAULT_COLORS } from './utils';
-/** Parse a start/end endpoint to a number: epoch-ms for temporal, else numeric. */
-function toNumber(value: unknown, temporal: boolean): number {
- if (value == null) return NaN;
- if (temporal) {
- if (value instanceof Date) return value.getTime();
- if (typeof value === 'number') return value;
- return Date.parse(String(value));
- }
- return Number(value);
-}
-
/** Format an epoch-ms value as a compact ISO date (YYYY-MM-DD). */
function fmtDate(ms: number): string {
if (!Number.isFinite(ms)) return '';
@@ -47,27 +44,31 @@ export const ecGanttChartDef: ChartTemplateDef = {
markCognitiveChannel: 'position',
declareLayoutMode: () => ({ axisFlags: { y: { banded: true } } }),
instantiate: (spec, ctx) => {
- const { channelSemantics, table } = ctx;
+ const { channelSemantics, table, chartProperties, semanticTypes } = ctx;
const taskField = channelSemantics.y?.field;
const startField = channelSemantics.x?.field;
const endField = channelSemantics.x2?.field;
const colorField = channelSemantics.color?.field;
if (!taskField || !startField || !endField || table.length === 0) return;
- const temporal = channelSemantics.x?.type === 'temporal';
+ const temporal = isGanttTemporal(channelSemantics.x?.type, semanticTypes[startField]);
// One row per task, sorted by start ascending so the timeline reads
// chronologically. The y-axis is inverted below, so the earliest task,
// first in this list, ends up at the top.
- const rows = table
- .map((r: any) => ({
+ const rows = sortGanttRows(table
+ .map((r: any, inputIndex: number) => ({
task: String(r[taskField] ?? ''),
- start: toNumber(r[startField], temporal),
- end: toNumber(r[endField], temporal),
+ start: coerceGanttEndpoint(r[startField], temporal),
+ end: coerceGanttEndpoint(r[endField], temporal),
group: colorField != null ? String(r[colorField] ?? '') : undefined,
+ inputIndex,
}))
- .filter((r) => r.task && Number.isFinite(r.start) && Number.isFinite(r.end))
- .sort((a, b) => a.start - b.start);
+ .filter((r) => r.task && Number.isFinite(r.start) && Number.isFinite(r.end)));
+ const taskHeight = chartProperties?.taskHeight ?? 70;
+ const cornerRadius = chartProperties?.cornerRadius ?? 2;
+ const intervalLabels = chartProperties?.intervalLabels === true;
+ const labelReserve = ganttLabelReservePx(rows, temporal);
const tasks = rows.map((r) => r.task);
@@ -98,7 +99,10 @@ export const ecGanttChartDef: ChartTemplateDef = {
return `${r.task} ${startField}: ${s} ${endField}: ${e}${grp}`;
},
},
- grid: { containLabel: true },
+ grid: {
+ containLabel: true,
+ ...(intervalLabels ? { right: labelReserve } : {}),
+ },
xAxis: {
type: 'value',
scale: true,
@@ -125,15 +129,23 @@ export const ecGanttChartDef: ChartTemplateDef = {
itemStyle: { color: 'transparent' },
silent: true,
emphasis: { disabled: true },
- barWidth: '62%',
+ barWidth: `${taskHeight}%`,
},
{
type: 'bar',
name: 'Task',
stack: 'gantt',
data: durationData,
- barWidth: '62%',
- itemStyle: { borderRadius: 2 },
+ barWidth: `${taskHeight}%`,
+ itemStyle: { borderRadius: cornerRadius },
+ label: {
+ show: intervalLabels,
+ position: 'right',
+ formatter: (p: any) => {
+ const row = rows[p.dataIndex];
+ return row ? formatGanttLabel(row.start, row.end, temporal) : '';
+ },
+ },
},
],
};
@@ -149,7 +161,7 @@ export const ecGanttChartDef: ChartTemplateDef = {
name: g,
stack: 'gantt',
data: [],
- barWidth: '62%',
+ barWidth: `${taskHeight}%`,
itemStyle: { color: groupColor.get(g) },
});
}
@@ -160,4 +172,5 @@ export const ecGanttChartDef: ChartTemplateDef = {
delete spec.mark;
delete spec.encoding;
},
+ properties: GANTT_PROPERTIES,
};
diff --git a/packages/flint-js/src/echarts/templates/jitter.ts b/packages/flint-js/src/echarts/templates/jitter.ts
index fd76556d..9e3d33a4 100644
--- a/packages/flint-js/src/echarts/templates/jitter.ts
+++ b/packages/flint-js/src/echarts/templates/jitter.ts
@@ -199,12 +199,6 @@ export const ecStripPlotDef: ChartTemplateDef = {
delete spec.encoding;
},
pivot: makeCartesianPivot({
- transitions: [
- {
- to: 'Scatter Plot',
- label: 'Scatter',
- route: { from: 'color', to: 'x', mode: 'swap', spill: 'color' },
- },
- ],
+ // θ (Strip → Scatter) declared centrally in core/chart-transitions.ts.
}),
};
diff --git a/packages/flint-js/src/echarts/templates/line.ts b/packages/flint-js/src/echarts/templates/line.ts
index 58545456..27e96b88 100644
--- a/packages/flint-js/src/echarts/templates/line.ts
+++ b/packages/flint-js/src/echarts/templates/line.ts
@@ -289,7 +289,7 @@ export const ecLineChartDef: ChartTemplateDef = {
{ value: 'step-after', label: 'Step After' },
],
} as ChartPropertyDef,
- { key: 'showPoints', label: 'Show points', type: 'binary', defaultValue: false } as ChartPropertyDef,
+ { key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false } as ChartPropertyDef,
],
pivot: makeCartesianPivot({
permute: [['y', 'color']],
diff --git a/packages/flint-js/src/echarts/templates/scatter.ts b/packages/flint-js/src/echarts/templates/scatter.ts
index d5c71df7..6309e596 100644
--- a/packages/flint-js/src/echarts/templates/scatter.ts
+++ b/packages/flint-js/src/echarts/templates/scatter.ts
@@ -609,13 +609,7 @@ export const ecScatterPlotDef: ChartTemplateDef = {
transpose: [['x', 'y']],
permute: [['x', 'y', 'color', 'size']],
shift: ['color', 'group', 'column', 'row'],
- transitions: [
- {
- to: 'Strip Plot',
- label: 'Jitter',
- route: { from: 'series', to: 'x', mode: 'swap', spill: 'color' },
- },
- ],
+ // θ transitions declared centrally in core/chart-transitions.ts.
}),
postProcess: (option, ctx) => {
if (!option.series || !Array.isArray(option.series)) return;
diff --git a/packages/flint-js/src/echarts/templates/waterfall.ts b/packages/flint-js/src/echarts/templates/waterfall.ts
index 4db44c4c..e6034a83 100644
--- a/packages/flint-js/src/echarts/templates/waterfall.ts
+++ b/packages/flint-js/src/echarts/templates/waterfall.ts
@@ -6,7 +6,7 @@
*/
import { ChartTemplateDef } from '../../core/types';
-import { resolveTotalsMode } from '../../core/waterfall';
+import { resolveTotalsMode } from '../../chart-types/waterfall';
import { extractCategories } from './utils';
/** True if all category labels parse as numbers → horizontal; else vertical (align with line/bar). */
@@ -38,7 +38,7 @@ export const ecWaterfallChartDef: ChartTemplateDef = {
const hasTypeCol = !!colorField;
// Which bars (if any) touch down to zero as full "total" bars. The default
- // is data-aware (see core/waterfall.ts): first is a start total, last is a
+ // is data-aware (see chart-types/waterfall.ts): first is a start total, last is a
// total only when it reconciles with the prior cumulative. The user's
// `totals` property overrides this; an explicit Type column is
// authoritative. Mirror vegalite/templates/waterfall.ts.
diff --git a/packages/flint-js/src/test-data/area-tests.ts b/packages/flint-js/src/test-data/area-tests.ts
index 155e2db1..4f919be4 100644
--- a/packages/flint-js/src/test-data/area-tests.ts
+++ b/packages/flint-js/src/test-data/area-tests.ts
@@ -41,7 +41,7 @@ interface AreaMatrixEntry {
}
// ============================================================================
-// AREA CHART MATRIX — one row per test case (23 tests)
+// AREA CHART MATRIX — one row per test case (21 tests)
//
// Note: O (ordinal) is used for categorical axes — area charts require
// a meaningful sequential order. N (nominal) is used for color groups.
@@ -50,33 +50,33 @@ interface AreaMatrixEntry {
// ============================================================================
const AREA_MATRIX: AreaMatrixEntry[] = [
- // ── T × Q (7 tests) — core stacked / layered area ───────────────
+ // ── T × Q (6 tests) — core stacked / layered area ───────────────
{ x: 'T', y: 'Q', n: 30, desc: 'Simple time-series area — 30 dates' },
{ x: 'T', y: 'Q', n: 96, color: 'N', colorCard: 4, desc: '4 stacked series × 24 dates' },
{ x: 'T', y: 'Q', n: 480, color: 'N', colorCard: 8, desc: '8 series × 60 dates — large stacked' },
{ x: 'T', y: 'Q', n: 1800, color: 'N', colorCard: 15, desc: '15 series × 120 dates — stress', extraTags: ['stress'] },
{ x: 'T', y: 'Q', n: 120, color: 'N', colorCard: 3, desc: '3 layered/overlapping series' },
{ x: 'T', y: 'Q', n: 180, color: 'N', colorCard: 3, sparse: true, desc: '3 series, ~20% missing values' },
- { x: 'T', y: 'Q', n: 30, color: 'Q', desc: 'Continuous color gradient on area' },
- // ── O × Q (4 tests) — ordered categories on x ───────────────────
+ // ── O × Q (3 tests) — ordered categories on x ───────────────────
// Area charts with ordinal x make sense when categories have an
// inherent sequence (e.g. stages, ranked items, ordered groups).
{ x: 'O', y: 'Q', n: 5, xCard: 5, desc: 'Ordinal area — 5 ordered categories' },
{ x: 'O', y: 'Q', n: 48, xCard: 12, color: 'N', colorCard: 4, desc: '12 ordinal × 4 stacked series' },
{ x: 'O', y: 'Q', n: 30, xCard: 30, desc: '30 ordinal categories — label overflow', extraTags: ['overflow'] },
- { x: 'O', y: 'Q', n: 5, xCard: 5, color: 'Q', desc: 'Ordinal + continuous color gradient' },
-
- // ── Q × O (3 tests) — mirror ────────────────────────────────────
- { x: 'Q', y: 'O', n: 5, yCard: 5, desc: 'Horizontal ordinal — 5 ordered cats on y' },
- { x: 'Q', y: 'O', n: 48, yCard: 12, color: 'N', colorCard: 4, desc: 'Horizontal 12 ordinal × 4 series' },
- { x: 'Q', y: 'O', n: 30, yCard: 30, desc: 'Horizontal 30 ordinal overflow', extraTags: ['overflow'] },
// ── Q × Q (3 tests) — quantitative both axes ────────────────────
{ x: 'Q', y: 'Q', n: 30, desc: 'Quantitative x area — 30 pts' },
{ x: 'Q', y: 'Q', n: 150, color: 'N', colorCard: 3, desc: '3 stacked curves × 50 pts' },
{ x: 'Q', y: 'Q', n: 200, desc: 'Dense single-series area — 200 pts' },
+ // Excluded: continuous (Q) COLOR on an area — Vega-Lite fills the region
+ // with a per-vertex gradient that collapses each segment to the baseline
+ // (meaningless overlapping wedges). An area's fill can't carry a continuous
+ // third variable; use a line chart (neutral line + gradient points) instead.
+ // Excluded: Q×O (horizontal area on an ordinal/discrete y) — an area fill
+ // across discrete categories reads as a meaningless polygon; use a bar,
+ // lollipop or funnel there instead.
// Excluded: T×T, Q×T — date-pair data doesn't suit area charts.
// Area fills imply sequential progression; T×T/Q×T lack monotonic relationships.
// Excluded: N×N, T×N, N×T — purely nominal axes don't suit area charts.
diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts
index 121ad3ab..59a4c7a3 100644
--- a/packages/flint-js/src/vegalite/assemble.ts
+++ b/packages/flint-js/src/vegalite/assemble.ts
@@ -56,7 +56,7 @@ import type { ChartWarning, ChartOption, OptionEvalContext } from '../core/types
import { applyEncodingOverrides } from '../core/encoding-overrides';
import { applyAggregation } from '../core/aggregate';
import { planBandDodge, resolveDodge } from '../core/band-dodge';
-import { applyPivot, type PivotSurface } from '../core/pivot';
+import { applyPivot, applyTransform, type PivotSurface, type TransformSurface } from '../core/pivot';
import { vlGetTemplateDef } from './templates';
import { inferVisCategory, computeZeroDecision } from '../core/semantic-types';
import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics';
@@ -182,23 +182,27 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
typedRawEncodings[axis] = { ...typedRawEncodings[axis], type: choice };
}
}
- // Pivot (derived Category-B operator): re-route fields across position/
- // legend/facet channels to surface alternative views (orientation swap,
- // series↔axis role swap, facet split). Composed BEFORE other overrides so
- // sort/overflow/layout all resolve against the post-pivot channel map. The
- // chosen state id is stored by the host under chartProperties[pivot.key];
- // the resolved surface is surfaced to hosts via `_pivot` / getChartPivot.
- const pivoted = applyPivot(chartTemplate, typedRawEncodings, data, chartProperties, vlGetTemplateDef);
- // A chart-type *transition* pivot state re-renders the same data as a sibling
+ // Transform (derived Category-B operator): re-route fields across position/
+ // legend/facet channels + optionally re-render as a sibling chart type, to
+ // surface alternative views. Split into TWO independent controls (see
+ // design-docs/chart-transform-two-axes.md):
+ // - Control B (chart type, θ): stored under chartProperties.chartType
+ // - Control A (arrange, τ/σ/γ): stored under chartProperties.arrange
+ // (A legacy composed `pivot` id is migrated by applyTransform.) Composed
+ // BEFORE other overrides so sort/overflow/layout resolve against the result.
+ // Both surfaces are exposed to hosts via `_transform` / getChartTransform;
+ // the legacy single-control `_pivot` surface is still emitted below.
+ const authoredTemplate = chartTemplate;
+ const transformed = applyTransform(chartTemplate, typedRawEncodings, data, chartProperties, vlGetTemplateDef);
+ // A chart-type *transition* (Control B) re-renders the same data as a sibling
// chart type (e.g. Grouped Bar → Stacked Bar, Scatter → Strip/Jitter). The
- // authored chartType / encodings are untouched; the pivot enumeration above
- // ran against the authored template, but rendering now re-dispatches to the
- // sibling template so its instantiate / layout logic takes over. See §4.6.
- if (pivoted.chartType && pivoted.chartType !== chartType) {
- const swapped = vlGetTemplateDef(pivoted.chartType) as ChartTemplateDef | undefined;
+ // authored chartType / encodings are untouched; rendering re-dispatches to
+ // the sibling template so its instantiate / layout logic takes over. See §4.6.
+ if (transformed.chartType && transformed.chartType !== chartType) {
+ const swapped = vlGetTemplateDef(transformed.chartType) as ChartTemplateDef | undefined;
if (swapped) chartTemplate = swapped;
}
- const composedEncodings = applyEncodingOverrides(chartTemplate, pivoted.encodings, chartProperties);
+ const composedEncodings = applyEncodingOverrides(chartTemplate, transformed.encodings, chartProperties);
// Template-level encoding normalization (e.g. Sparkline remaps its series
// field onto the `row` facet channel). Runs BEFORE semantics/layout so the
@@ -513,6 +517,36 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
chartTemplate.instantiate(vgObj, instantiateContext);
+ // Facet-identity augmentation is presentation-only: the structural series
+ // has already moved to column/row for semantics, layout, stacking, and
+ // dodging. Reapply its learned color now so faceted panels preserve visual
+ // identity without making the compiler treat color as another series role.
+ if (transformed.augmentation?.kind === 'facet-identity') {
+ const facetEncoding = resolvedEncodings[transformed.augmentation.facetChannel];
+ if (facetEncoding?.field) {
+ const colorEncoding = { ...facetEncoding };
+ delete colorEncoding.header;
+ delete colorEncoding.axis;
+ delete colorEncoding.columns;
+ const scheme = transformed.augmentation.colorEncoding.scheme;
+ if (scheme) {
+ colorEncoding.scale = { ...(colorEncoding.scale ?? {}), scheme };
+ }
+ vgObj.encoding = vgObj.encoding || {};
+ vgObj.encoding.color = colorEncoding;
+
+ // Vega-Lite implicitly stacks bars/areas whenever a discrete color
+ // channel is present. This color is redundant identity, not a stack
+ // series, so pin the quantitative position channel to unstacked.
+ for (const axis of ['x', 'y']) {
+ const encoding = vgObj.encoding[axis];
+ if (encoding?.type === 'quantitative' && encoding.stack === undefined) {
+ encoding.stack = null;
+ }
+ }
+ }
+ }
+
// Merge any warnings emitted by instantiate
if (vgObj._warnings && Array.isArray(vgObj._warnings)) {
warnings.push(...vgObj._warnings);
@@ -668,8 +702,16 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
// active index) for the current encodings + data. Hosts read this to render
// a cyclic prev/next control and write the chosen id back to
// chartProperties[pivot.key]. Absent when the chart has <= 1 state.
- if (pivoted.surface) {
- result._pivot = pivoted.surface;
+ if (transformed.surface) {
+ result._transform = transformed.surface;
+ }
+ // Legacy single-control surface (composed orbit) for backward-compat
+ // consumers (getChartPivot). Enumerated from the AUTHORED template so its
+ // ids/labels match the pre-split contract. Rendering above is driven by
+ // `transformed` (the two-control model); this is surface-only.
+ const legacyPivot = applyPivot(authoredTemplate, typedRawEncodings, data, chartProperties, vlGetTemplateDef);
+ if (legacyPivot.surface) {
+ result._pivot = legacyPivot.surface;
}
return result;
}
@@ -709,6 +751,21 @@ export function getChartPivot(input: ChartAssemblyInput): PivotSurface | undefin
return spec && spec._pivot ? (spec._pivot as PivotSurface) : undefined;
}
+/**
+ * Inspect a chart spec + dataset and report the two-control transform surface
+ * Flint exposes for it — Control B (chart-type transitions, a dropdown) and
+ * Control A (local rearrangement group, a stepper). Either sub-surface is
+ * absent when that control has a single state. Mirrors getChartPivot but for the
+ * factored model (design-docs/chart-transform-two-axes.md). Hosts render a
+ * dropdown seeded from `chartType.index` (writing the chosen id to
+ * chartProperties.chartType) and a cyclic stepper seeded from `arrange.index`
+ * (writing chartProperties.arrange).
+ */
+export function getChartTransform(input: ChartAssemblyInput): TransformSurface | undefined {
+ const spec = assembleVegaLite(input);
+ return spec && spec._transform ? (spec._transform as TransformSurface) : undefined;
+}
+
// ===========================================================================
// buildVLEncodings — Translate abstract semantics → VL encoding objects
// ===========================================================================
diff --git a/packages/flint-js/src/vegalite/format.ts b/packages/flint-js/src/vegalite/format.ts
new file mode 100644
index 00000000..efc50b33
--- /dev/null
+++ b/packages/flint-js/src/vegalite/format.ts
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { FormatSpec } from '../core/field-semantics';
+
+const quoteVegaString = (value: string): string =>
+ `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
+
+/** Compile a semantic FormatSpec into a Vega expression for any numeric value. */
+export function formatSpecToVegaExpr(
+ fmt: FormatSpec,
+ valueExpr: string = 'datum.value',
+): string | null {
+ const prefix = fmt.prefix ? `${quoteVegaString(fmt.prefix)} + ` : '';
+ const suffix = fmt.suffix ? ` + ${quoteVegaString(fmt.suffix)}` : '';
+
+ if (fmt.abbreviate) {
+ return (
+ `${prefix}(abs(${valueExpr}) >= 1e12 ? format(${valueExpr} / 1e12, '~g') + 'T' : ` +
+ `abs(${valueExpr}) >= 1e9 ? format(${valueExpr} / 1e9, '~g') + 'B' : ` +
+ `abs(${valueExpr}) >= 1e6 ? format(${valueExpr} / 1e6, '~g') + 'M' : ` +
+ `abs(${valueExpr}) >= 1e3 ? format(${valueExpr} / 1e3, '~g') + 'K' : ` +
+ `format(${valueExpr}, ','))${suffix}`
+ );
+ }
+
+ if (!fmt.pattern) return prefix || suffix ? `${prefix}${valueExpr}${suffix}` : null;
+ return `${prefix}format(${valueExpr}, ${quoteVegaString(fmt.pattern)})${suffix}`;
+}
+
+/**
+ * Compile a FormatSpec for an axis/legend labelExpr. A pattern without affixes
+ * returns null because Vega-Lite can apply it directly through `format`.
+ */
+export function formatSpecToLabelExpr(fmt: FormatSpec): string | null {
+ if (!fmt.abbreviate && fmt.pattern && !fmt.prefix && !fmt.suffix) return null;
+ return formatSpecToVegaExpr(fmt);
+}
\ No newline at end of file
diff --git a/packages/flint-js/src/vegalite/index.ts b/packages/flint-js/src/vegalite/index.ts
index 35234919..c6a5d62e 100644
--- a/packages/flint-js/src/vegalite/index.ts
+++ b/packages/flint-js/src/vegalite/index.ts
@@ -11,7 +11,7 @@
*/
// VL assembly function
-export { assembleVegaLite, getChartOptions, getChartPivot } from './assemble';
+export { assembleVegaLite, getChartOptions, getChartPivot, getChartTransform } from './assemble';
// VL spec instantiation (Phase 2)
export { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec';
@@ -25,4 +25,4 @@ export {
} from './templates';
// VL recommendation & adaptation
-export { vlAdaptChart, vlRecommendEncodings } from './recommendation';
+export { vlAdaptChart, vlRecommendEncodings, vlRecommendChartTypes, vlRecommendCharts } from './recommendation';
diff --git a/packages/flint-js/src/vegalite/instantiate-spec.ts b/packages/flint-js/src/vegalite/instantiate-spec.ts
index 5cd6d8e4..642d4faa 100644
--- a/packages/flint-js/src/vegalite/instantiate-spec.ts
+++ b/packages/flint-js/src/vegalite/instantiate-spec.ts
@@ -21,7 +21,7 @@ import type {
InstantiateContext,
ChartWarning,
} from '../core/types';
-import type { FormatSpec } from '../core/field-semantics';
+import { formatSpecToLabelExpr } from './format';
import { snapToBoundHeuristic } from '../core/field-semantics';
const DEFAULT_QUANTITATIVE_AXIS_FORMAT = ',.12~g';
@@ -469,66 +469,6 @@ export function vlApplyLayoutToSpec(
// vlApplyFieldContext — Apply field-level semantic decisions to VL encodings
// ---------------------------------------------------------------------------
-/**
- * Build a Vega expression that abbreviates large numbers using a small
- * set of universally understood suffixes: K (thousands), M (millions),
- * B (billions), T (trillions).
- *
- * The expression is a nested ternary that picks the right divisor:
- * abs(v) >= 1e12 → v/1e12 + "T"
- * abs(v) >= 1e9 → v/1e9 + "B"
- * abs(v) >= 1e6 → v/1e6 + "M"
- * abs(v) >= 1e3 → v/1e3 + "K"
- * else → plain number
- *
- * @param prefix Optional prefix (e.g., "$")
- * @param suffix Optional suffix (e.g., " kg")
- * @returns A Vega labelExpr string
- */
-function buildAbbreviationExpr(prefix?: string, suffix?: string): string {
- const pfx = prefix ? `'${prefix}' + ` : '';
- const sfx = suffix ? ` + '${suffix}'` : '';
- // Use ~g to drop trailing zeros from the fractional digit
- return (
- `${pfx}(abs(datum.value) >= 1e12 ? format(datum.value / 1e12, '~g') + 'T' : ` +
- `abs(datum.value) >= 1e9 ? format(datum.value / 1e9, '~g') + 'B' : ` +
- `abs(datum.value) >= 1e6 ? format(datum.value / 1e6, '~g') + 'M' : ` +
- `abs(datum.value) >= 1e3 ? format(datum.value / 1e3, '~g') + 'K' : ` +
- `format(datum.value, ','))${sfx}`
- );
-}
-
-/**
- * Build a VL-compatible format expression from a FormatSpec.
- *
- * - d3's `format()` handles the numeric pattern.
- * - Prefix/suffix are prepended/appended via a Vega expression.
- * - When `abbreviate` is true, large values are compacted (1K, 1M, 1B, 1T).
- *
- * Returns an `axis.labelExpr` / `legend.labelExpr` string, or null if
- * no formatting is needed (plain data labels suffice).
- */
-function formatSpecToLabelExpr(fmt: FormatSpec): string | null {
- // Abbreviation takes priority — produces its own complete expression
- if (fmt.abbreviate) {
- return buildAbbreviationExpr(fmt.prefix, fmt.suffix);
- }
-
- if (!fmt.pattern) return null;
- const hasPrefix = !!fmt.prefix;
- const hasSuffix = !!fmt.suffix;
-
- if (!hasPrefix && !hasSuffix) {
- // Pure d3-format — can use axis.format directly (no expr needed)
- return null;
- }
-
- // Build Vega expression: format(datum.value, pattern) with prefix/suffix
- const pfx = hasPrefix ? `'${fmt.prefix}' + ` : '';
- const sfx = hasSuffix ? ` + '${fmt.suffix}'` : '';
- return `${pfx}format(datum.value, '${fmt.pattern}')${sfx}`;
-}
-
/**
* Compute the positive and negative stacked extremes for a quantitative field.
*
diff --git a/packages/flint-js/src/vegalite/recommendation.ts b/packages/flint-js/src/vegalite/recommendation.ts
index 690b3ec5..52ea6c49 100644
--- a/packages/flint-js/src/vegalite/recommendation.ts
+++ b/packages/flint-js/src/vegalite/recommendation.ts
@@ -29,7 +29,12 @@ import {
isValidLineSeriesData,
nameMatches,
} from '../core/recommendation';
-import { vlGetTemplateChannels } from './templates';
+import {
+ recommendChartTypes,
+ type RecommendChartTypesOptions,
+ type RecommendedChart,
+} from '../core/chart-type-recommendation';
+import { vlGetTemplateChannels, vlAllTemplateDefs } from './templates';
// ── VL-extended recommendation ──────────────────────────────────────────
@@ -237,3 +242,49 @@ export function vlRecommendEncodings(
}
return result;
}
+
+/** Chart-type names Vega-Lite can render (its template catalog). */
+const VL_SUPPORTED_TYPES = vlAllTemplateDefs.map(d => d.chart);
+
+/**
+ * Recommend a ranked list of Vega-Lite chart types for a dataset.
+ *
+ * Wraps the backend-agnostic `recommendChartTypes`, restricting results to
+ * chart types Vega-Lite can render. Call it first, then feed the chosen type to
+ * {@link vlRecommendEncodings} to populate channels — or use
+ * {@link vlRecommendCharts} to do both in one step.
+ *
+ * @param data Array of row objects.
+ * @param semanticTypes Field→semantic-type map (e.g. `{ Entity: "Country" }`).
+ * @param options Optional `max` cap on the number of suggestions.
+ * @returns Ranked VL chart-type names (best first).
+ */
+export function vlRecommendChartTypes(
+ data: any[],
+ semanticTypes: Record,
+ options: Omit = {},
+): string[] {
+ return recommendChartTypes(data, semanticTypes, { ...options, supportedTypes: VL_SUPPORTED_TYPES });
+}
+
+/**
+ * One-step recommendation: rank Vega-Lite chart types for the data, then
+ * populate each with {@link vlRecommendEncodings}. Suggestions whose required
+ * channels cannot be filled are dropped, so every returned chart is renderable.
+ *
+ * @param data Array of row objects.
+ * @param semanticTypes Field→semantic-type map.
+ * @param options Optional `max` cap on the number of charts returned.
+ * @returns Ranked `{ chartType, encodings }` pairs (best first).
+ */
+export function vlRecommendCharts(
+ data: any[],
+ semanticTypes: Record,
+ options: Omit = {},
+): RecommendedChart[] {
+ const { max, ...rest } = options;
+ const charts = vlRecommendChartTypes(data, semanticTypes, rest)
+ .map(chartType => ({ chartType, encodings: vlRecommendEncodings(chartType, data, semanticTypes) }))
+ .filter(s => Object.keys(s.encodings).length > 0);
+ return max != null ? charts.slice(0, max) : charts;
+}
diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts
index 26d70ea8..7bef3b80 100644
--- a/packages/flint-js/src/vegalite/templates/area.ts
+++ b/packages/flint-js/src/vegalite/templates/area.ts
@@ -24,6 +24,94 @@ function applyInterpolate(vgSpec: any, config?: Record): void {
vgSpec.mark = setMarkProp(vgSpec.mark, 'interpolate', config.interpolate);
}
+/**
+ * A single-series area (no `color` to stack) still gets an implicit zero-offset
+ * stack from Vega-Lite. When such an area is FACETED (`column`/`row`, sharing the
+ * domain scale), that stack makes VL auto-inject an `impute {value: 0}` for every
+ * missing `(domain × facet)` cell — which spikes each gap down to the baseline.
+ * Disabling the stack on the measure axis removes the impute, so the area simply
+ * connects its *measured* points across gaps (a trend line between real data —
+ * NOT a fabricated data row), exactly like a standalone single-series area.
+ */
+function disableImplicitStack(spec: any): void {
+ for (const axis of ['x', 'y'] as const) {
+ const e = spec.encoding?.[axis];
+ if (e && (e.type === 'quantitative' || e.aggregate)) { e.stack = null; break; }
+ }
+}
+
+/**
+ * Align a sparse STACKED area/stream. Stacking is only defined when every series
+ * has a value at every domain point — with holes, Vega-Lite fills them with 0
+ * (spiking each gap to the baseline). A stack fundamentally *must* decide each
+ * hole's value; the standard, defensible choice is to **linearly interpolate**
+ * between a series' real neighbours (a trend estimate, not a flat 0). We compute
+ * it in JS (VL's `impute` has no linear method) and inject the aligned rows as
+ * the spec's data. Grouped by every discrete series/facet channel; only over a
+ * CONTINUOUS domain (temporal/quantitative). A no-op when already dense.
+ *
+ * NOTE: this only runs when a series is *stacked* (a `color` split). A single
+ * series is handled by {@link disableImplicitStack} with no fabricated data.
+ */
+function interpolateSparseStack(spec: any, ctx: any): void {
+ const enc = ctx.resolvedEncodings;
+ const { x, y } = enc;
+ if (!x?.field || !y?.field) return;
+ const xMeasure = x.type === 'quantitative' || !!x.aggregate;
+ const yMeasure = y.type === 'quantitative' || !!y.aggregate;
+ let measureF: string;
+ let domain: any;
+ if (yMeasure && !xMeasure) { measureF = y.field; domain = x; }
+ else if (xMeasure && !yMeasure) { measureF = x.field; domain = y; }
+ else return;
+ if (domain.type !== 'temporal' && domain.type !== 'quantitative') return;
+ const domainF = domain.field;
+
+ const groupFields: string[] = [];
+ for (const ch of ['color', 'group', 'column', 'row']) {
+ const e = enc[ch];
+ if (e?.field && (e.type === 'nominal' || e.type === 'ordinal')) groupFields.push(e.field);
+ }
+ if (groupFields.length === 0) return;
+
+ const table = ctx.table;
+ if (!Array.isArray(table) || table.length === 0) return;
+
+ const num = (v: any): number =>
+ v instanceof Date ? v.getTime() : (typeof v === 'number' ? v : Date.parse(v));
+ const domainSorted = [...new Set(table.map((r: any) => r[domainF]))].sort((a, b) => num(a) - num(b));
+
+ const groups = new Map();
+ for (const r of table) {
+ const k = groupFields.map((f) => r[f]).join('\u0001');
+ (groups.get(k) ?? groups.set(k, []).get(k)!).push(r);
+ }
+ if (table.length >= domainSorted.length * groups.size) return; // already dense
+
+ const out: any[] = [];
+ for (const pts0 of groups.values()) {
+ const pts = pts0.sort((a: any, b: any) => num(a[domainF]) - num(b[domainF]));
+ const have = new Map(pts.map((p: any) => [p[domainF], p]));
+ for (const d of domainSorted) {
+ const existing = have.get(d);
+ if (existing) { out.push(existing); continue; }
+ const p = num(d);
+ let prev: any = null;
+ let next: any = null;
+ for (const pt of pts) {
+ const pv = num(pt[domainF]);
+ if (pv < p) prev = pt;
+ else if (pv > p) { next = pt; break; }
+ }
+ const val = (prev && next)
+ ? prev[measureF] + ((p - num(prev[domainF])) / (num(next[domainF]) - num(prev[domainF]))) * (next[measureF] - prev[measureF])
+ : (prev ?? next)[measureF];
+ out.push({ ...(prev ?? next), [domainF]: d, [measureF]: val });
+ }
+ }
+ spec.data = { values: out };
+}
+
export const areaChartDef: ChartTemplateDef = {
chart: "Area Chart",
template: { mark: "area", encoding: {} },
@@ -50,10 +138,22 @@ export const areaChartDef: ChartTemplateDef = {
}
}
}
+ // Sparse-gap handling. A SINGLE series just connects its measured points
+ // (disable VL's phantom stack — no fabricated data). A STACKED series
+ // (colour) can't stack over holes, so interpolate them (unavoidable fill,
+ // a trend estimate instead of VL's spiky 0) — unless it's layered.
+ if (!ctx.resolvedEncodings.color?.field) {
+ disableImplicitStack(spec);
+ } else if (config?.stackMode !== 'layered') {
+ interpolateSparseStack(spec, ctx);
+ }
},
properties: [
interpolateConfigProperty,
- { key: "opacity", label: "Opacity", type: "continuous", min: 0.1, max: 1, step: 0.1, defaultValue: 0.7 },
+ { key: "opacity", label: "Opacity", type: "continuous", min: 0.1, max: 1, step: 0.1, defaultValue: 0.7,
+ check: (ctx) => ({
+ applicable: !!ctx.encodings.color?.field && ctx.chartProperties?.stackMode === 'layered',
+ }) },
{ key: "stackMode", label: "Stack", type: "discrete",
// A stack mode only does something when a series dimension (color) is
// present to stack; without it there is a single area band.
@@ -93,6 +193,9 @@ export const streamgraphDef: ChartTemplateDef = {
spec.encoding.x.axis = null;
}
applyInterpolate(spec, ctx.chartProperties);
+ // A streamgraph is always centre-stacked → interpolate sparse gaps so the
+ // stack stays continuous (see interpolateSparseStack).
+ interpolateSparseStack(spec, ctx);
},
properties: [interpolateConfigProperty] as ChartPropertyDef[],
};
diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts
index 7157a1d9..eaab140a 100644
--- a/packages/flint-js/src/vegalite/templates/bar-table.ts
+++ b/packages/flint-js/src/vegalite/templates/bar-table.ts
@@ -4,6 +4,7 @@
import { ChartTemplateDef, ChartPropertyDef, ChannelSemantics } from '../../core/types';
import { getRegistryEntry } from '../../core/type-registry';
import type { FormatSpec } from '../../core/field-semantics';
+import { formatSpecToVegaExpr } from '../format';
/**
* Bar Table — a ranked horizontal "data bar table".
@@ -617,20 +618,13 @@ export const barTableDef: ChartTemplateDef = {
if (!fmt || (!fmt.pattern && !fmt.prefix && !fmt.suffix)) {
return { field: sourceField, type: 'quantitative' };
}
- const hasAffix = !!(fmt.prefix || fmt.suffix);
- if (!hasAffix) {
+ if (!fmt.abbreviate && fmt.pattern && !fmt.prefix && !fmt.suffix) {
return { field: sourceField, type: 'quantitative', format: fmt.pattern };
}
- // Escape backslashes first (so we don't double-escape the ones
- // we add next), then escape single quotes for safe embedding in
- // the Vega expression string literal.
- const escPfx = (fmt.prefix ?? '').replace(/\\/g, "\\\\").replace(/'/g, "\\'");
- const escSfx = (fmt.suffix ?? '').replace(/\\/g, "\\\\").replace(/'/g, "\\'");
- const formatExpr = fmt.pattern
- ? `format(datum['${sourceField}'], '${fmt.pattern}')`
- : `datum['${sourceField}']`;
+ const formatExpr = formatSpecToVegaExpr(fmt, `datum[${JSON.stringify(sourceField)}]`);
+ if (!formatExpr) return { field: sourceField, type: 'quantitative' };
transformsOut.push({
- calculate: `'${escPfx}' + ${formatExpr} + '${escSfx}'`,
+ calculate: formatExpr,
as: outFieldHint,
});
return { field: outFieldHint, type: 'nominal' };
@@ -799,7 +793,7 @@ export const barTableDef: ChartTemplateDef = {
// meaningful (additive, single-sign, non-zero total). Its `check`
// reports applicability per render from the measure's data.
{
- key: 'showPercent', label: 'Show % of Total', type: 'binary', defaultValue: false,
+ key: 'showPercent', label: '% of total', type: 'binary', defaultValue: false,
check: (ctx) => {
// A "% of total" share only reads sensibly for an additive,
// single-sign measure with a non-zero total — a share of a
diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts
index bbd467a0..35216767 100644
--- a/packages/flint-js/src/vegalite/templates/bar.ts
+++ b/packages/flint-js/src/vegalite/templates/bar.ts
@@ -67,7 +67,7 @@ function getSafeHeatmapIntrinsicDomain(ctx: any, colorField: string | undefined)
export const barChartDef: ChartTemplateDef = {
chart: "Bar Chart",
template: { mark: "bar", encoding: {} },
- channels: ["x", "y", "color", "group", "opacity", "column", "row"],
+ channels: ["x", "y", "color", "opacity", "column", "row"],
markCognitiveChannel: 'length',
declareLayoutMode: (cs, table) => {
const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' });
@@ -91,7 +91,7 @@ export const barChartDef: ChartTemplateDef = {
pivot: makeCartesianPivot({
transpose: [['x', 'y']],
permute: [['x', 'y', 'color']],
- shift: ['color', 'group', 'column', 'row'],
+ shift: ['color', 'column', 'row'],
}),
};
@@ -333,14 +333,7 @@ export const groupedBarChartDef: ChartTemplateDef = {
transpose: [['x', 'y']],
permute: [['x', 'y', 'color']],
shift: ['color', 'group', 'column', 'row'],
- transitions: [
- {
- to: 'Stacked Bar Chart',
- label: 'Stacked',
- route: { from: 'group', to: 'color', mode: 'move' },
- requireDiscreteSource: true,
- },
- ],
+ // θ (→ Stacked Bar) is declared centrally in core/chart-transitions.ts.
}),
};
@@ -384,7 +377,6 @@ export const stackedBarChartDef: ChartTemplateDef = {
{ value: undefined, label: "Stacked (default)" },
{ value: "normalize", label: "Normalize (100%)" },
{ value: "center", label: "Center" },
- { value: "layered", label: "Layered (overlap)" },
] },
] as ChartPropertyDef[],
encodingActions: [makeSortAction()] as EncodingActionDef[],
@@ -396,15 +388,7 @@ export const stackedBarChartDef: ChartTemplateDef = {
transpose: [['x', 'y']],
permute: [['x', 'y', 'color']],
shift: ['color', 'group', 'column', 'row'],
- transitions: [
- {
- to: 'Grouped Bar Chart',
- label: 'Grouped',
- route: { from: 'color', to: 'group', mode: 'move' },
- requireDiscreteSource: true,
- maxSourceCardinality: 12,
- },
- ],
+ // θ (→ Grouped Bar) is declared centrally in core/chart-transitions.ts.
}),
};
@@ -442,9 +426,7 @@ export const histogramDef: ChartTemplateDef = {
// smooth kernel Density Plot.
pivot: makeCartesianPivot({
shift: ['color', 'column', 'row'],
- transitions: [
- { to: 'Density Plot', label: 'Density' },
- ],
+ // θ (→ Density Plot / ECDF) is declared centrally in core/chart-transitions.ts.
}),
};
@@ -601,7 +583,7 @@ export const heatmapDef: ChartTemplateDef = {
}
},
properties: [
- { key: 'showTextLabels', label: 'Show labels', type: 'binary', defaultValue: false },
+ { key: 'showTextLabels', label: 'Labels', type: 'binary', defaultValue: false },
] as ChartPropertyDef[],
// Color scheme is an encoding-level edit (writes encoding.scheme on the
// color channel), so it is exposed as a Category-B encoding action rather
diff --git a/packages/flint-js/src/vegalite/templates/density.ts b/packages/flint-js/src/vegalite/templates/density.ts
index 2b7b5b8f..d1ed1b54 100644
--- a/packages/flint-js/src/vegalite/templates/density.ts
+++ b/packages/flint-js/src/vegalite/templates/density.ts
@@ -114,8 +114,6 @@ export const densityPlotDef: ChartTemplateDef = {
// back to a binned Histogram.
pivot: makeCartesianPivot({
shift: ['color', 'column', 'row'],
- transitions: [
- { to: 'Histogram', label: 'Histogram' },
- ],
+ // θ (→ Histogram / ECDF) is declared centrally in core/chart-transitions.ts.
}),
};
diff --git a/packages/flint-js/src/vegalite/templates/ecdf.ts b/packages/flint-js/src/vegalite/templates/ecdf.ts
index 0c09b2cb..7972da76 100644
--- a/packages/flint-js/src/vegalite/templates/ecdf.ts
+++ b/packages/flint-js/src/vegalite/templates/ecdf.ts
@@ -32,7 +32,7 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types';
import { setMarkProp } from './utils';
const showPointsProperty: ChartPropertyDef = {
- key: 'showPoints', label: 'Show points', type: 'binary', defaultValue: false,
+ key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false,
};
/**
diff --git a/packages/flint-js/src/vegalite/templates/gantt.ts b/packages/flint-js/src/vegalite/templates/gantt.ts
index 746a642b..4451301a 100644
--- a/packages/flint-js/src/vegalite/templates/gantt.ts
+++ b/packages/flint-js/src/vegalite/templates/gantt.ts
@@ -2,6 +2,13 @@
// Licensed under the MIT License.
import { ChartTemplateDef } from '../../core/types';
+import {
+ coerceGanttEndpoint,
+ ganttDurationLabelExpression,
+ ganttLabelReservePx,
+ GANTT_PROPERTIES,
+ isGanttTemporal,
+} from '../../chart-types/gantt';
/**
* Gantt chart — one horizontal bar per task, spanning [start, end].
@@ -30,24 +37,29 @@ export const ganttChartDef: ChartTemplateDef = {
}),
instantiate: (spec, ctx) => {
const { x, x2, y, color, detail, column, row } = ctx.resolvedEncodings;
+ const taskHeight = (ctx.chartProperties?.taskHeight ?? 70) / 100;
+ const cornerRadius = ctx.chartProperties?.cornerRadius ?? 2;
+ const intervalLabels = ctx.chartProperties?.intervalLabels === true;
+ const temporal = isGanttTemporal(x?.type, x?.field ? ctx.semanticTypes[x.field] : undefined);
+
+ spec.mark = { type: 'bar', cornerRadius, height: { band: taskHeight } };
if (!spec.encoding) spec.encoding = {};
if (y) {
spec.encoding.y = { ...y };
spec.encoding.y.axis = { ...(spec.encoding.y.axis ?? {}), title: null };
- // Order tasks by when they start so the timeline reads chronologically.
if (x?.field) {
spec.encoding.y.sort = { field: x.field, op: 'min', order: 'ascending' };
}
}
if (x) {
- spec.encoding.x = { ...x };
+ spec.encoding.x = { ...x, ...(temporal ? { type: 'temporal' } : {}) };
spec.encoding.x.axis = { ...(spec.encoding.x.axis ?? {}), title: null };
// A non-zero baseline only matters for a quantitative interval; on a
// time scale Vega-Lite ignores (and warns about) scale.zero.
- if (x.type === 'quantitative') {
+ if (!temporal && x.type === 'quantitative') {
spec.encoding.x.scale = { ...(spec.encoding.x.scale ?? {}), zero: false };
}
}
@@ -58,5 +70,51 @@ export const ganttChartDef: ChartTemplateDef = {
if (detail) spec.encoding.detail = detail;
if (column) spec.encoding.column = column;
if (row) spec.encoding.row = row;
+
+ if (intervalLabels && x?.field && x2?.field && y) {
+ const labelField = '__gantt_label';
+ spec.transform = [
+ ...(spec.transform ?? []),
+ { calculate: ganttDurationLabelExpression(x.field, x2.field, temporal), as: labelField },
+ ];
+ const intervals = ctx.table
+ .map((row: any, inputIndex: number) => ({
+ task: '',
+ start: coerceGanttEndpoint(row[x.field], temporal),
+ end: coerceGanttEndpoint(row[x2.field], temporal),
+ inputIndex,
+ }))
+ .filter((row) => Number.isFinite(row.start) && Number.isFinite(row.end));
+ const reservePx = ganttLabelReservePx(intervals, temporal);
+ const existingPadding = Number(spec.encoding.x.scale?.padding) || 0;
+ spec.encoding.x.scale = {
+ ...(spec.encoding.x.scale ?? {}),
+ padding: Math.max(existingPadding, reservePx),
+ };
+ const barEncoding = { ...spec.encoding };
+ const facetEncoding: Record = {};
+ if (column) facetEncoding.column = column;
+ if (row) facetEncoding.row = row;
+ delete barEncoding.column;
+ delete barEncoding.row;
+ spec.encoding = facetEncoding;
+ spec.layer = [
+ { mark: spec.mark, encoding: barEncoding },
+ {
+ mark: { type: 'text', align: 'left', baseline: 'middle', dx: 4, fontSize: 10 },
+ encoding: {
+ y: { ...y },
+ x: {
+ field: x2.field,
+ type: temporal ? 'temporal' : x.type,
+ ...(barEncoding.x?.scale ? { scale: barEncoding.x.scale } : {}),
+ },
+ text: { field: labelField, type: 'nominal' },
+ },
+ },
+ ];
+ delete spec.mark;
+ }
},
+ properties: GANTT_PROPERTIES,
};
diff --git a/packages/flint-js/src/vegalite/templates/index.ts b/packages/flint-js/src/vegalite/templates/index.ts
index a9e8c979..56c3c0a6 100644
--- a/packages/flint-js/src/vegalite/templates/index.ts
+++ b/packages/flint-js/src/vegalite/templates/index.ts
@@ -219,10 +219,11 @@ const AXIS_DTYPE_PROPERTIES: ChartPropertyDef[] = [
*/
function withInjectedProperties(def: ChartTemplateDef): ChartTemplateDef {
const hasFacetChannels = def.channels?.some(ch => ch === 'column' || ch === 'row');
+ const supportsFacetAxisControl = hasFacetChannels && def.chart !== 'Sparkline';
const isPosition = def.markCognitiveChannel === 'position';
const wantsAxisDtype = AXIS_DTYPE_CHARTS.has(def.chart);
const extra: ChartPropertyDef[] = [
- ...(hasFacetChannels ? FACET_AXIS_PROPERTIES : []),
+ ...(supportsFacetAxisControl ? FACET_AXIS_PROPERTIES : []),
...(isPosition ? LOG_SCALE_PROPERTIES : []),
...(isPosition ? ZERO_BASELINE_PROPERTIES : []),
...(wantsAxisDtype ? AXIS_DTYPE_PROPERTIES : []),
diff --git a/packages/flint-js/src/vegalite/templates/jitter.ts b/packages/flint-js/src/vegalite/templates/jitter.ts
index f1599e28..64ce922c 100644
--- a/packages/flint-js/src/vegalite/templates/jitter.ts
+++ b/packages/flint-js/src/vegalite/templates/jitter.ts
@@ -119,18 +119,7 @@ export const stripPlotDef: ChartTemplateDef = {
{ key: "pointSize", label: "Size", type: "continuous", min: 0, max: 150, step: 5, defaultValue: 0 },
{ key: "opacity", label: "Opacity", type: "continuous", min: 0, max: 1, step: 0.1, defaultValue: 0 },
] as ChartPropertyDef[],
- pivot: makeCartesianPivot({
- // Reverse of the scatter→Jitter hop: promote the spilled measure on
- // `color` back onto the `x` axis, and the categorical `x` spills back to
- // `color` as a discrete series — re-rendering the strip as a scatter
- // cloud. Inside a scatter's orbit this folds onto Default (θ round-trip);
- // standalone it gives a Strip Plot its own way back to a scatter.
- transitions: [
- {
- to: 'Scatter Plot',
- label: 'Scatter',
- route: { from: 'color', to: 'x', mode: 'swap', spill: 'color' },
- },
- ],
- }),
+ // θ chart-type transition (Strip → Scatter) is declared centrally in
+ // core/chart-transitions.ts; the strip plot has no local τ/σ/γ group.
+ pivot: makeCartesianPivot({}),
};
diff --git a/packages/flint-js/src/vegalite/templates/line.ts b/packages/flint-js/src/vegalite/templates/line.ts
index a167e313..484d1a5a 100644
--- a/packages/flint-js/src/vegalite/templates/line.ts
+++ b/packages/flint-js/src/vegalite/templates/line.ts
@@ -20,7 +20,7 @@ export const interpolateConfigProperty: ChartPropertyDef = {
};
const showPointsProperty: ChartPropertyDef = {
- key: "showPoints", label: "Show points", type: "binary", defaultValue: false,
+ key: "showPoints", label: "Points", type: "binary", defaultValue: false,
};
export function applyInterpolate(mark: any, config?: Record): any {
diff --git a/packages/flint-js/src/vegalite/templates/lollipop.ts b/packages/flint-js/src/vegalite/templates/lollipop.ts
index ecc08ae9..11f94086 100644
--- a/packages/flint-js/src/vegalite/templates/lollipop.ts
+++ b/packages/flint-js/src/vegalite/templates/lollipop.ts
@@ -55,7 +55,7 @@ export const lollipopChartDef: ChartTemplateDef = {
const yType = yEnc?.type;
const isMeasure = (t: string | undefined) =>
- t != null && t !== 'nominal' && t !== 'ordinal';
+ t != null && t !== 'nominal' && t !== 'ordinal' && t !== 'temporal';
if (isMeasure(yType)) {
spec.layer[0].encoding.y2 = { datum: 0 };
diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts
index 15ae20b0..0f8d3fe1 100644
--- a/packages/flint-js/src/vegalite/templates/scatter.ts
+++ b/packages/flint-js/src/vegalite/templates/scatter.ts
@@ -54,17 +54,8 @@ export const scatterPlotDef: ChartTemplateDef = {
// Route the discrete grouping field across color / facet channels so a
// grouped scatter and a faceted scatter are states of one another.
shift: ['color', 'group', 'column', 'row'],
- // Chart-type transition: the discrete series field (wherever it sits —
- // color, column or row) moves onto the `x` category axis, re-rendering
- // the cloud as a Strip/Jitter plot. The displaced quantitative x spills
- // to a `color` gradient. Offered whenever a discrete series exists.
- transitions: [
- {
- to: 'Strip Plot',
- label: 'Jitter',
- route: { from: 'series', to: 'x', mode: 'swap', spill: 'color' },
- },
- ],
+ // θ chart-type transitions (Scatter → Strip / Regression) are declared
+ // centrally in core/chart-transitions.ts, not on the template.
}),
};
@@ -145,6 +136,15 @@ export const regressionDef: ChartTemplateDef = {
min: 2, max: 10, step: 1, defaultValue: 3,
},
] as ChartPropertyDef[],
+ // A regression is a scatter with a fitted trend, so it shares the scatter's
+ // local rearrangement group: flip the axes, demote a measure to color/size,
+ // and route a discrete series across color / facet channels. (θ chart-type
+ // transitions are declared centrally in core/chart-transitions.ts.)
+ pivot: makeCartesianPivot({
+ transpose: [['x', 'y']],
+ permute: [['x', 'y', 'color', 'size']],
+ shift: ['color', 'group', 'column', 'row'],
+ }),
};
export const rangedDotPlotDef: ChartTemplateDef = {
@@ -244,6 +244,8 @@ export const boxplotDef: ChartTemplateDef = {
// single-band branch below (one full-width box per category).
const colorEnc = spec.encoding?.color;
let subgroups = 1;
+ let localSeparatorAxis: 'x' | 'y' | undefined;
+ let localSeparatorValues: Record[] = [];
const colorField = ctx.channelSemantics?.color?.field;
const axisField = hasDiscreteX
? ctx.channelSemantics?.x?.field
@@ -275,6 +277,9 @@ export const boxplotDef: ChartTemplateDef = {
{ joinaggregate: [{ op: 'distinct', field: colorField, as: '__localCount' }], groupby: [axisField] },
{ calculate: `((datum.__laneIdx - 1) - (datum.__localCount - 1) / 2) / ${maxPB}`, as: '__off' },
];
+ localSeparatorAxis = hasDiscreteX ? 'x' : 'y';
+ const categories = [...new Set((ctx.fullTable ?? ctx.table).map((row) => row[axisField]))];
+ localSeparatorValues = categories.slice(0, -1).map((category) => ({ [axisField]: category }));
} else {
// Global: a fixed lane per distinct color across all bands.
const offsetEnc: Record = { field: colorEnc.field, type: 'nominal' };
@@ -300,6 +305,29 @@ export const boxplotDef: ChartTemplateDef = {
spec.mark = setMarkProp(spec.mark, 'size', boxSize);
}
}
+
+ if (localSeparatorAxis && localSeparatorValues.length > 0) {
+ const boxLayer = { mark: spec.mark, encoding: spec.encoding, transform: spec.transform };
+ const axisEncoding = spec.encoding[localSeparatorAxis];
+ spec.layer = [
+ {
+ data: { values: localSeparatorValues },
+ mark: { type: 'rule', stroke: '#c9ced6', strokeDash: [4, 4], strokeWidth: 1, opacity: 0.75 },
+ encoding: {
+ [localSeparatorAxis]: {
+ field: axisField,
+ type: 'nominal',
+ sort: axisEncoding.sort,
+ bandPosition: 1,
+ },
+ },
+ },
+ boxLayer,
+ ];
+ delete spec.mark;
+ delete spec.encoding;
+ delete spec.transform;
+ }
},
properties: [
{
diff --git a/packages/flint-js/src/vegalite/templates/sparkline.ts b/packages/flint-js/src/vegalite/templates/sparkline.ts
index 4757ca9f..30875495 100644
--- a/packages/flint-js/src/vegalite/templates/sparkline.ts
+++ b/packages/flint-js/src/vegalite/templates/sparkline.ts
@@ -2,6 +2,8 @@
// Licensed under the MIT License.
import { ChartTemplateDef, ChartPropertyDef, ChartEncoding } from '../../core/types';
+import type { FormatSpec } from '../../core/field-semantics';
+import { formatSpecToVegaExpr } from '../format';
import { interpolateConfigProperty, applyInterpolate } from './line';
/**
@@ -97,6 +99,8 @@ const approxNum = (v: number): string => {
if (a >= 1e3) return (v / 1e3).toFixed(1) + 'k';
return String(Math.round(v * 10) / 10);
};
+const approxFormattedNum = (v: number, fmt?: FormatSpec): string =>
+ `${fmt?.prefix ?? ''}${approxNum(v)}${fmt?.suffix ?? ''}${fmt?.pattern ? '0' : ''}`;
export const sparklineDef: ChartTemplateDef = {
chart: 'Sparkline',
@@ -144,15 +148,6 @@ export const sparklineDef: ChartTemplateDef = {
const hasColor = !!enc.color?.field;
const baseline = (ctx.chartProperties?.baseline as string) ?? 'mean';
const useMedian = baseline === 'median';
- // Per-strip independent Y is the default: each strip self-scales to its
- // own range so the trace fills the band and sits centered next to its
- // category label and value (Tufte's "dataword" — shape-at-a-glance). A
- // shared scale instead pins every trace to one global range, which
- // shoves low-level series to the strip floor (and high ones to the top),
- // visibly misaligning them from their centered label/number. Set
- // `independentYAxis: false` to opt back into the shared, level-comparable
- // scale. Applies to the trend facet's per-row y resolution.
- const independentY = ctx.chartProperties?.independentYAxis !== false;
// Guard: without both position fields there is no trend to draw; leave
// a valid single-line spec so assembly still succeeds.
@@ -199,6 +194,19 @@ export const sparklineDef: ChartTemplateDef = {
const catData = regions.map(r => ({ [facetField]: r }));
const avgData = regions.map(r => ({ [facetField]: r, flintSparkAvg: aggOf(r) }));
+ const valueFmt = ctx.channelSemantics?.y?.format;
+ const avgTransforms: any[] = [];
+ let avgTextEncoding: any = { field: 'flintSparkAvg', type: 'quantitative', format: '.3~s' };
+ const valueExpr = valueFmt
+ ? formatSpecToVegaExpr(valueFmt, 'datum.flintSparkAvg')
+ : null;
+ if (valueExpr) {
+ avgTransforms.push({
+ calculate: valueExpr,
+ as: 'flintSparkAvgFormatted',
+ });
+ avgTextEncoding = { field: 'flintSparkAvgFormatted', type: 'nominal' };
+ }
// ── Sizing (manual, like the Bar Table — the layout engine's faceted
// sizing doesn't apply to a hand-built hconcat). Column widths follow
@@ -214,7 +222,7 @@ export const sparklineDef: ChartTemplateDef = {
const maxCatChars = Math.max(textWidth(categoryTitle), 4,
...regions.map(r => textWidth(r)));
const maxAvgChars = Math.max(textWidth(avgTitle), 4,
- ...avgData.map(d => textWidth(approxNum(d.flintSparkAvg))));
+ ...avgData.map(d => textWidth(approxFormattedNum(d.flintSparkAvg, valueFmt))));
const catW = Math.min(200, Math.max(40, Math.round(maxCatChars * CHAR_PX) + 10));
const avgW = Math.min(96, Math.max(34, Math.round(maxAvgChars * CHAR_PX) + 8));
// Compact by default (DEFAULT_TREND_W), tunable via `trendWidth`, and
@@ -280,11 +288,7 @@ export const sparklineDef: ChartTemplateDef = {
data: { values: trendData },
facet: { row: facetRow },
spec: { width: trendW, height: stripH, layer: layers },
- // Per-row y resolution: `independent` (default) self-scales each
- // strip so its trace fills the band and aligns with its row label;
- // `shared` (via `independentYAxis: false`) keeps every row on one
- // comparable scale.
- resolve: { scale: { y: independentY ? 'independent' : 'shared' } },
+ resolve: { scale: { y: 'independent' } },
title: { text: trendTitle, anchor: 'middle', ...HEADER_STYLE },
};
const avgPanel = {
@@ -292,11 +296,12 @@ export const sparklineDef: ChartTemplateDef = {
facet: { row: facetRow },
spec: {
width: avgW, height: stripH,
+ ...(avgTransforms.length ? { transform: avgTransforms } : {}),
mark: { type: 'text', align: 'right', baseline: 'middle', fontSize: 11, fontWeight: 600 },
encoding: {
y: { value: stripH / 2 },
x: { value: avgW },
- text: { field: 'flintSparkAvg', type: 'quantitative', format: '.3~s' },
+ text: avgTextEncoding,
...(hasColor
? { color: { field: facetField, type: 'nominal', legend: null } }
: { color: { value: MONO_VALUE } }),
diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts
index cd4ea450..292eb820 100644
--- a/packages/flint-js/src/vegalite/templates/waterfall.ts
+++ b/packages/flint-js/src/vegalite/templates/waterfall.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { ChartTemplateDef, ChartPropertyDef } from '../../core/types';
-import { resolveTotalsMode } from '../../core/waterfall';
+import { resolveTotalsMode } from '../../chart-types/waterfall';
/**
* Waterfall Chart template.
@@ -43,7 +43,7 @@ export const waterfallChartDef: ChartTemplateDef = {
if (!hasTypeCol) {
// Which bars (if any) are drawn as full "total" bars that touch down to
- // zero. The default is data-aware (see core/waterfall.ts): the first bar
+ // zero. The default is data-aware (see chart-types/waterfall.ts): the first bar
// is a start total and the last bar is a total only when its value
// reconciles with the running cumulative of the prior rows; otherwise it
// stays a floating delta. The user's `totals` property overrides this.
@@ -279,9 +279,9 @@ export const waterfallChartDef: ChartTemplateDef = {
// explicit Type column. When the user binds a color/Type field their
// start/delta/end is authoritative, so the toggle is not offered. The
// default "auto" resolves to the data-aware recommendation inside the
- // template (see core/waterfall.ts resolveTotalsMode).
+ // template (see chart-types/waterfall.ts resolveTotalsMode).
check: (ctx) => ({ applicable: !ctx.encodings?.color?.field }),
},
- { key: 'showTextLabels', label: 'Show labels', type: 'binary', defaultValue: false },
+ { key: 'showTextLabels', label: 'Labels', type: 'binary', defaultValue: false },
] as ChartPropertyDef[],
};
diff --git a/packages/flint-js/tests/area-sparse.test.ts b/packages/flint-js/tests/area-sparse.test.ts
new file mode 100644
index 00000000..e1b4c84c
--- /dev/null
+++ b/packages/flint-js/tests/area-sparse.test.ts
@@ -0,0 +1,76 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { describe, it, expect } from 'vitest';
+import { assembleVegaLite, getChartOptions } from '../src';
+
+// Series 'A' is missing 2020-02, series 'B' is missing 2020-03.
+const SPARSE = [
+ { d: '2020-01-01', v: 10, s: 'A' },
+ { d: '2020-03-01', v: 30, s: 'A' },
+ { d: '2020-01-01', v: 5, s: 'B' },
+ { d: '2020-02-01', v: 9, s: 'B' },
+];
+
+describe('sparse area gap handling — no fabricated data', () => {
+ it('a single-series area disables the implicit stack so gaps connect', () => {
+ const spec = assembleVegaLite({
+ data: { values: [{ d: '2020-01-01', v: 10 }, { d: '2020-03-01', v: 30 }] },
+ semantic_types: { d: 'Date', v: 'Quantity' },
+ chart_spec: { chartType: 'Area Chart', encodings: { x: 'd', y: 'v' }, baseSize: { width: 400, height: 300 } },
+ });
+ // Measure axis stack disabled → VL won't auto-impute → area connects gaps.
+ expect(spec.encoding.y.stack).toBe(null);
+ // Data is NOT fabricated: the 2 measured rows are untouched.
+ expect((spec.data.values as any[]).length).toBe(2);
+ });
+
+ it('a FACETED single-series sparse area disables the stack, does NOT densify', () => {
+ const spec = assembleVegaLite({
+ data: { values: SPARSE },
+ semantic_types: { d: 'Date', v: 'Quantity', s: 'Category' },
+ chart_spec: { chartType: 'Area Chart', encodings: { x: 'd', y: 'v', column: 's' }, baseSize: { width: 600, height: 300 } },
+ });
+ expect(spec.encoding.y.stack).toBe(null);
+ // No fabricated cells — the 4 sparse rows are untouched.
+ expect((spec.data.values as any[]).length).toBe(SPARSE.length);
+ });
+
+ it('a colour-stacked area interpolates missing cells (stacking needs alignment)', () => {
+ const spec = assembleVegaLite({
+ data: { values: SPARSE },
+ semantic_types: { d: 'Date', v: 'Quantity', s: 'Category' },
+ chart_spec: { chartType: 'Area Chart', encodings: { x: 'd', y: 'v', color: 's' }, baseSize: { width: 400, height: 300 } },
+ });
+ // Stacking can't have holes → cells aligned to 3 dates × 2 series = 6.
+ expect((spec.data.values as any[]).length).toBe(6);
+ // A's missing 2020-02 is interpolated between 10 (Jan) and 30 (Mar) → 20.
+ const aFeb = (spec.data.values as any[]).find((r) => r.s === 'A' && String(r.d).startsWith('2020-02'));
+ expect(Math.round(aFeb.v)).toBe(20);
+ expect(spec.encoding.y.stack).not.toBe(null); // still stacked
+ });
+});
+
+describe('area opacity option applicability', () => {
+ const input = (chartProperties?: Record, withColor = true) => ({
+ data: { values: SPARSE },
+ semantic_types: { d: 'Date', v: 'Quantity', s: 'Category' },
+ chart_spec: {
+ chartType: 'Area Chart',
+ encodings: { x: 'd', y: 'v', ...(withColor ? { color: 's' } : {}) },
+ chartProperties,
+ baseSize: { width: 400, height: 300 },
+ },
+ });
+ const opacityApplicable = (chartInput: ReturnType) =>
+ getChartOptions(chartInput).find((option) => option.key === 'opacity')?.applicable;
+
+ it('hides opacity for stacked or single-series areas', () => {
+ expect(opacityApplicable(input())).toBe(false);
+ expect(opacityApplicable(input(undefined, false))).toBe(false);
+ });
+
+ it('shows opacity for layered multi-series areas', () => {
+ expect(opacityApplicable(input({ stackMode: 'layered' }))).toBe(true);
+ });
+});
diff --git a/packages/flint-js/tests/band-dodge.test.ts b/packages/flint-js/tests/band-dodge.test.ts
index efae66c5..b3c883f6 100644
--- a/packages/flint-js/tests/band-dodge.test.ts
+++ b/packages/flint-js/tests/band-dodge.test.ts
@@ -31,6 +31,7 @@ describe('planBandDodge — mode recommendation', () => {
expect(plan.mode).toBe('global');
expect(plan.maxPerBand).toBe(2);
expect(plan.global).toBe(2);
+ expect(plan.ambiguous).toBe(false);
});
it('sparse cross-product (1 < maxPerBand < global) → local', () => {
@@ -64,9 +65,11 @@ describe('planBandDodge — mode recommendation', () => {
expect(plan.maxPerBand).toBe(2);
});
- it('ambiguous flag is set whenever a real dodge choice exists (maxPerBand > 1)', () => {
+ it('ambiguous flag is set only when local and global layouts can differ', () => {
const sparse = planBandDodge(rows({ A: ['x', 'y'], B: ['y', 'z'] }), 'cat', 'sub');
expect(sparse.ambiguous).toBe(true);
+ const complete = planBandDodge(rows({ A: ['x', 'y'], B: ['x', 'y'] }), 'cat', 'sub');
+ expect(complete.ambiguous).toBe(false);
const none = planBandDodge(rows({ A: ['A'], B: ['B'] }), 'cat', 'sub');
expect(none.ambiguous).toBe(false);
});
diff --git a/packages/flint-js/tests/boxplot-grouped-dodge.test.ts b/packages/flint-js/tests/boxplot-grouped-dodge.test.ts
index 363953e9..0025a93c 100644
--- a/packages/flint-js/tests/boxplot-grouped-dodge.test.ts
+++ b/packages/flint-js/tests/boxplot-grouped-dodge.test.ts
@@ -197,13 +197,20 @@ describe('boxplot color redundant with axis (no dodge)', () => {
};
// Auto → local: centered quantitative offset + the computed transforms.
const local = assembleVegaLite(input as any) as any;
- expect(local.encoding?.xOffset?.field).toBe('__off');
- expect(local.transform?.some((t: any) => t.as === '__off')).toBe(true);
+ const boxLayer = local.layer?.find((layer: any) => layer.mark?.type === 'boxplot');
+ const separatorLayer = local.layer?.find((layer: any) => layer.mark?.type === 'rule');
+ expect(boxLayer?.encoding?.xOffset?.field).toBe('__off');
+ expect(boxLayer?.transform?.some((t: any) => t.as === '__off')).toBe(true);
+ expect(separatorLayer?.mark?.strokeDash).toEqual([4, 4]);
+ expect(separatorLayer?.encoding?.x?.bandPosition).toBe(1);
+ expect(separatorLayer?.encoding?.x?.axis).not.toBeNull();
+ expect(separatorLayer?.data?.values).toHaveLength(depts.length - 1);
// Forced global → the fixed per-color lane grid, sized to the 5 global lanes.
input.chart_spec.chartProperties = { dodge: 'global' };
const global = assembleVegaLite(input as any) as any;
expect(global.encoding?.xOffset?.field).toBe('Level');
+ expect(global.encoding?.x?.axis?.tickBand).toBeUndefined();
const fiveLanePitch = (stepOf(global) * 0.8) / 5;
expect(sizeOf(global)).toBeLessThanOrEqual(fiveLanePitch);
});
@@ -239,6 +246,78 @@ describe('ECharts boxplot color redundant with axis', () => {
});
});
+describe('ECharts sparse grouped boxplot', () => {
+ const sparseInput = (dodge: 'global' | 'local') => {
+ const groups: Record = {
+ A: ['G1', 'G2'], B: ['G2'], C: ['G3', 'G4'], D: ['G4', 'G1'],
+ };
+ const rows: any[] = [];
+ for (const [category, presentGroups] of Object.entries(groups)) {
+ for (const group of presentGroups) {
+ for (let index = 0; index < 8; index++) {
+ rows.push({ Category: category, Group: group, Score: index === 7 ? 100 : index + 1 });
+ }
+ }
+ }
+ return {
+ data: { values: rows },
+ semantic_types: { Category: 'Category', Group: 'Category', Score: 'Quantity' },
+ chart_spec: {
+ chartType: 'Boxplot',
+ encodings: { x: 'Category', y: 'Score', color: 'Group' },
+ chartProperties: { dodge },
+ baseSize: { width: 500, height: 320 },
+ },
+ } as any;
+ };
+
+ it.each(['global', 'local'] as const)('uses ECharts missing-value sentinels for %s dodge gaps', (dodge) => {
+ const option = assembleECharts(sparseInput(dodge)) as any;
+ const cells = option.series
+ .filter((series: any) => series.type === 'boxplot')
+ .flatMap((series: any) => series.data);
+
+ expect(cells).toContain('-');
+ expect(cells).not.toContain(null);
+ });
+
+ it('offsets global outliers onto their boxplot lane', () => {
+ const option = assembleECharts(sparseInput('global')) as any;
+ const outliers = option.series.find((series: any) => series.name === 'G1 (outliers)');
+ expect(outliers?.type).toBe('custom');
+
+ const api = {
+ value: (index: number) => [0, 100][index],
+ coord: ([category, value]: number[]) => [category * 100 + 50, 300 - value],
+ size: () => [100, 0],
+ visual: () => '#5470c6',
+ };
+ const rendered = outliers.renderItem({}, api);
+ expect(rendered.shape.cx).toBeLessThan(50);
+ expect(rendered.shape.cy).toBe(200);
+ });
+
+ it('adds dashed category separators only for local dodge', () => {
+ const local = assembleECharts(sparseInput('local')) as any;
+ const separators = local.series.find((series: any) => series.name === '__groupSeparators');
+ expect(separators?.type).toBe('custom');
+ expect(separators.data).toHaveLength(local.xAxis.data.length - 1);
+
+ const rendered = separators.renderItem(
+ { coordSys: { x: 10, y: 20, width: 400, height: 200 } },
+ {
+ value: () => 0,
+ coord: ([category]: number[]) => [50 + category * 100, 100],
+ },
+ );
+ expect(rendered.shape).toEqual({ x1: 100, y1: 20, x2: 100, y2: 220 });
+ expect(rendered.style.lineDash).toEqual([4, 4]);
+
+ const global = assembleECharts(sparseInput('global')) as any;
+ expect(global.series.some((series: any) => series.name === '__groupSeparators')).toBe(false);
+ });
+});
+
/**
* Grouped bar generalization: a `group` field that is redundant/nested with the
* categorical axis (group == x, or a 1:1 pair) must NOT dodge — otherwise every
diff --git a/packages/flint-js/tests/chart-type-recommendation.test.ts b/packages/flint-js/tests/chart-type-recommendation.test.ts
new file mode 100644
index 00000000..25ccc80f
--- /dev/null
+++ b/packages/flint-js/tests/chart-type-recommendation.test.ts
@@ -0,0 +1,211 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { describe, it, expect } from 'vitest';
+import {
+ profileData,
+ recommendChartTypes,
+ recommendChartTypesDetailed,
+ vlRecommendChartTypes,
+ vlRecommendCharts,
+ vlRecommendEncodings,
+ ecRecommendChartTypes,
+ cjsRecommendChartTypes,
+} from '../src';
+
+// ── The "Life Expectancy Gap" table from the handoff ──────────────────────
+// Entity/Code are Country (geo place), Year is temporal, the diff is a measure.
+const LIFE_EXP = (() => {
+ const entities = [
+ ['Afghanistan', 'AFG'],
+ ['Albania', 'ALB'],
+ ['Algeria', 'DZA'],
+ ];
+ const years = [1950, 1951, 1952];
+ const rows: any[] = [];
+ for (const [Entity, Code] of entities) {
+ for (const Year of years) {
+ rows.push({ Entity, Code, Year, LifeExpectancyDiffFM: 1.2 + rows.length * 0.03 });
+ }
+ }
+ return rows;
+})();
+const LIFE_EXP_TYPES = {
+ Entity: 'Country',
+ Code: 'Country',
+ Year: 'Year',
+ LifeExpectancyDiffFM: 'Number',
+};
+
+describe('data profiling', () => {
+ it('classifies fields into roles from semantic types', () => {
+ const p = profileData(LIFE_EXP, LIFE_EXP_TYPES);
+ expect(p.geoPlaces.map(f => f.name).sort()).toEqual(['Code', 'Entity']);
+ expect(p.temporals.map(f => f.name)).toEqual(['Year']);
+ expect(p.measures.map(f => f.name)).toEqual(['LifeExpectancyDiffFM']);
+ expect(p.categoricals).toHaveLength(0);
+ // geo places + the time field are all category-axis-capable dimensions.
+ expect(p.dimensions.map(f => f.name).sort()).toEqual(['Code', 'Entity', 'Year']);
+ expect(p.rowCount).toBe(9);
+ });
+
+ it('classifies latitude / longitude as coordinates, not measures', () => {
+ const p = profileData(
+ [{ lat: 40.7, lon: -74, mag: 5.1 }],
+ { lat: 'Latitude', lon: 'Longitude', mag: 'Number' },
+ );
+ expect(p.latitudes.map(f => f.name)).toEqual(['lat']);
+ expect(p.longitudes.map(f => f.name)).toEqual(['lon']);
+ expect(p.measures.map(f => f.name)).toEqual(['mag']);
+ });
+
+ it('demotes id-like fields so they are not measures or categories', () => {
+ const p = profileData(
+ [{ user_id: 1, amount: 10 }],
+ { user_id: 'ID', amount: 'Amount' },
+ );
+ expect(p.identifiers.map(f => f.name)).toEqual(['user_id']);
+ expect(p.measures.map(f => f.name)).toEqual(['amount']);
+ });
+});
+
+describe('recommendChartTypes — the headline geographic case', () => {
+ it('ranks Choropleth first, then Line and Bar for country + time + measure', () => {
+ const types = recommendChartTypes(LIFE_EXP, LIFE_EXP_TYPES);
+ expect(types.slice(0, 3)).toEqual(['Choropleth', 'Line Chart', 'Bar Chart']);
+ });
+
+ it('exposes scores and reasons in the detailed form', () => {
+ const detailed = recommendChartTypesDetailed(LIFE_EXP, LIFE_EXP_TYPES);
+ expect(detailed[0].chartType).toBe('Choropleth');
+ expect(detailed[0].score).toBeGreaterThan(detailed[1].score - 1); // sorted desc
+ expect(detailed[0].reasons.join(' ')).toMatch(/geographic/i);
+ // Monotonically non-increasing scores.
+ for (let i = 1; i < detailed.length; i++) {
+ expect(detailed[i - 1].score).toBeGreaterThanOrEqual(detailed[i].score);
+ }
+ });
+
+ it('is deterministic across repeated calls', () => {
+ const a = recommendChartTypes(LIFE_EXP, LIFE_EXP_TYPES);
+ const b = recommendChartTypes(LIFE_EXP, LIFE_EXP_TYPES);
+ expect(a).toEqual(b);
+ });
+});
+
+describe('recommendChartTypes — other data shapes', () => {
+ const first = (data: any[], types: Record) =>
+ recommendChartTypes(data, types)[0];
+
+ it('picks Line Chart for a time series', () => {
+ expect(first(
+ [{ date: '2020-01-01', sales: 3 }, { date: '2020-02-01', sales: 5 }],
+ { date: 'Date', sales: 'Amount' },
+ )).toBe('Line Chart');
+ });
+
+ it('picks Bar Chart for a single category + measure', () => {
+ const types = recommendChartTypes(
+ [{ region: 'A', sales: 3 }, { region: 'B', sales: 5 }, { region: 'C', sales: 2 }, { region: 'D', sales: 4 }],
+ { region: 'Category', sales: 'Amount' },
+ );
+ expect(types[0]).toBe('Bar Chart');
+ expect(types).toContain('Pie Chart'); // few categories, one row each → part-to-whole
+ });
+
+ it('picks Scatter Plot for two measures', () => {
+ expect(first(
+ [{ height: 1, weight: 2 }, { height: 3, weight: 4 }],
+ { height: 'Number', weight: 'Number' },
+ )).toBe('Scatter Plot');
+ });
+
+ it('picks Histogram for a lone measure', () => {
+ expect(first([{ v: 1 }, { v: 2 }, { v: 3 }], { v: 'Number' })).toBe('Histogram');
+ });
+
+ it('picks Map for latitude + longitude', () => {
+ expect(first(
+ [{ lat: 40.7, lon: -74, mag: 5 }, { lat: 34, lon: -118, mag: 4 }],
+ { lat: 'Latitude', lon: 'Longitude', mag: 'Number' },
+ )).toBe('Map');
+ });
+});
+
+describe('recommendChartTypes — options', () => {
+ it('filters to supportedTypes, preserving rank order', () => {
+ const types = recommendChartTypes(LIFE_EXP, LIFE_EXP_TYPES, {
+ supportedTypes: ['Bar Chart', 'Line Chart'],
+ });
+ expect(types).toEqual(['Line Chart', 'Bar Chart']);
+ });
+
+ it('caps the number of suggestions with max', () => {
+ expect(recommendChartTypes(LIFE_EXP, LIFE_EXP_TYPES, { max: 2 }))
+ .toEqual(['Choropleth', 'Line Chart']);
+ });
+});
+
+describe('backend wrappers', () => {
+ it('vlRecommendChartTypes restricts to Vega-Lite types and keeps Choropleth first', () => {
+ const types = vlRecommendChartTypes(LIFE_EXP, LIFE_EXP_TYPES);
+ expect(types[0]).toBe('Choropleth');
+ });
+
+ it('ecRecommendChartTypes / cjsRecommendChartTypes only return their own catalog', () => {
+ // ECharts and Chart.js have no Choropleth/Map template, so those geographic
+ // types must be filtered out even though the data is geographic.
+ const ec = ecRecommendChartTypes(LIFE_EXP, LIFE_EXP_TYPES);
+ const cjs = cjsRecommendChartTypes(LIFE_EXP, LIFE_EXP_TYPES);
+ expect(ec).not.toContain('Choropleth');
+ expect(ec).not.toContain('Map');
+ expect(ec.length).toBeGreaterThan(0);
+ expect(cjs).not.toContain('Choropleth');
+ expect(cjs.length).toBeGreaterThan(0);
+ });
+
+ it('vlRecommendCharts pairs each type with fillable encodings (one step)', () => {
+ const charts = vlRecommendCharts(LIFE_EXP, LIFE_EXP_TYPES);
+ // Every returned chart is renderable (non-empty encodings).
+ for (const c of charts) {
+ expect(Object.keys(c.encodings).length).toBeGreaterThan(0);
+ }
+ // The top pick is a Choropleth with the region on `id` and the measure on `color`.
+ const choropleth = charts.find(c => c.chartType === 'Choropleth')!;
+ expect(choropleth).toBeDefined();
+ expect(['Entity', 'Code']).toContain(choropleth.encodings.id);
+ expect(choropleth.encodings.color).toBe('LifeExpectancyDiffFM');
+ });
+});
+
+describe('encoding recommendation is deterministic and quality-aware', () => {
+ it('vlRecommendEncodings returns the same result on repeated calls', () => {
+ const runs = Array.from({ length: 5 }, () =>
+ vlRecommendEncodings('Line Chart', LIFE_EXP, LIFE_EXP_TYPES),
+ );
+ for (const r of runs) expect(r).toEqual(runs[0]);
+ // Time on x, measure on y, a country as the series.
+ expect(runs[0].x).toBe('Year');
+ expect(runs[0].y).toBe('LifeExpectancyDiffFM');
+ expect(['Entity', 'Code']).toContain(runs[0].color);
+ });
+
+ it('prefers the lowest-cardinality field for the color/series channel', () => {
+ // `label` (12 unique) makes every (label, series) pair unique, so both
+ // `tier` (2) and `group` (4) are valid grouping fields. The recommender
+ // must choose the smaller one for a more readable legend.
+ const rows = Array.from({ length: 12 }, (_, i) => ({
+ label: `L${i}`,
+ tier: i % 2 === 0 ? 'lo' : 'hi',
+ grp: `g${i % 4}`,
+ val: i,
+ }));
+ const enc = vlRecommendEncodings('Grouped Bar Chart', rows, {
+ label: 'Category', tier: 'Category', grp: 'Category', val: 'Amount',
+ });
+ expect(enc.x).toBe('label');
+ expect(enc.y).toBe('val');
+ // Grouped bars dodge by the `group` channel; the series is the 2-value field.
+ expect(enc.group).toBe('tier');
+ });
+});
diff --git a/packages/flint-js/tests/gantt-bullet-backends.test.ts b/packages/flint-js/tests/gantt-bullet-backends.test.ts
index 2c68ddc4..9f1723a9 100644
--- a/packages/flint-js/tests/gantt-bullet-backends.test.ts
+++ b/packages/flint-js/tests/gantt-bullet-backends.test.ts
@@ -69,6 +69,26 @@ describe('ECharts Gantt chart', () => {
expect(Number.isFinite(baseVal)).toBe(true);
expect(taskVal).toBeGreaterThan(0);
});
+
+ it('applies task height, corners, and interval labels while retaining start order', () => {
+ const input = toInput(tc) as any;
+ input.chart_spec.chartProperties = {
+ taskHeight: 55,
+ cornerRadius: 6,
+ intervalLabels: true,
+ };
+ const configured = assembleECharts(input) as any;
+ const configuredTask = configured.series.find((s: any) => s.name === 'Task');
+ const starts = configured.series.find((s: any) => s.name === '_base').data;
+
+ expect(configuredTask.barWidth).toBe('55%');
+ expect(configuredTask.itemStyle.borderRadius).toBe(6);
+ expect(configuredTask.label.show).toBe(true);
+ expect(configuredTask.label.position).toBe('right');
+ expect(configuredTask.label.formatter({ dataIndex: 0 })).toMatch(/d$/);
+ expect(configured.grid.right).toBeGreaterThanOrEqual(40);
+ expect(starts).toEqual([...starts].sort((a, b) => a - b));
+ });
});
describe('ECharts Bullet chart', () => {
@@ -132,6 +152,24 @@ describe('Chart.js Gantt chart', () => {
const sorted = [...starts].sort((a, b) => a - b);
expect(starts).toEqual(sorted);
});
+
+ it('applies task height, corners, and interval labels while retaining start order', () => {
+ const input = toInput(tc) as any;
+ input.chart_spec.chartProperties = {
+ taskHeight: 55,
+ cornerRadius: 6,
+ intervalLabels: true,
+ };
+ const configured = assembleChartjs(input) as any;
+ const configuredDataset = configured.data.datasets[0];
+
+ expect(configuredDataset.barPercentage).toBe(0.55);
+ expect(configuredDataset.borderRadius).toBe(6);
+ const starts = configuredDataset.data.map((value: [number, number]) => value[0]);
+ expect(starts).toEqual([...starts].sort((a, b) => a - b));
+ expect(configured.plugins[0].id).toBe('ganttLabels');
+ expect(configured.options.layout.padding.right).toBeGreaterThanOrEqual(40);
+ });
});
describe('Chart.js Waterfall chart', () => {
diff --git a/packages/flint-js/tests/gantt-bullet.test.ts b/packages/flint-js/tests/gantt-bullet.test.ts
index 86cb9067..000b2ae4 100644
--- a/packages/flint-js/tests/gantt-bullet.test.ts
+++ b/packages/flint-js/tests/gantt-bullet.test.ts
@@ -2,7 +2,8 @@
// Licensed under the MIT License.
import { describe, it, expect } from 'vitest';
-import { assembleVegaLite } from '../src';
+import { assembleVegaLite, getChartOptions } from '../src';
+import { coerceGanttEndpoint, formatGanttDuration, sortGanttRows } from '../src/chart-types/gantt';
import { genGanttTests, genBulletTests } from '../src/test-data';
import type { TestCase } from '../src/test-data/types';
@@ -42,6 +43,43 @@ function toInput(tc: TestCase) {
describe('Gantt chart', () => {
const spec = assembleVegaLite(toInput(genGanttTests()[0])) as any;
+ it('formats elapsed durations with an adaptive unit', () => {
+ expect(formatGanttDuration(0)).toBe('0ms');
+ expect(formatGanttDuration(500)).toBe('500ms');
+ expect(formatGanttDuration(30_000)).toBe('30s');
+ expect(formatGanttDuration(90_000)).toBe('1.5min');
+ expect(formatGanttDuration(7_200_000)).toBe('2h');
+ expect(formatGanttDuration(129_600_000)).toBe('1.5d');
+ });
+
+ it('distinguishes epoch seconds from millisecond timestamps before 2001', () => {
+ expect(coerceGanttEndpoint(1_700_000_000, true)).toBe(1_700_000_000_000);
+ expect(coerceGanttEndpoint(946_684_800_000, true)).toBe(946_684_800_000);
+ });
+
+ it('keeps an explicitly temporal one-task interval temporal', () => {
+ const input = {
+ data: { values: [{ task: 'Review', start: '2026-07-19T09:00:00Z', end: '2026-07-19T10:30:00Z' }] },
+ semantic_types: { task: 'Category', start: 'DateTime', end: 'DateTime' },
+ chart_spec: {
+ chartType: 'Gantt Chart',
+ encodings: { y: 'task', x: 'start', x2: 'end' },
+ chartProperties: { intervalLabels: true },
+ },
+ };
+ const configured = assembleVegaLite(input as any) as any;
+
+ expect(configured.layer[0].encoding.x.type).toBe('temporal');
+ expect(configured.transform[0].calculate).toContain("'h'");
+ });
+
+ it('exposes labels as an off-by-default binary toggle', () => {
+ const labels = getChartOptions(toInput(genGanttTests()[0]) as any)
+ .find((option) => option.key === 'intervalLabels');
+
+ expect(labels).toMatchObject({ type: 'binary', value: false, applicable: true });
+ });
+
it('renders a single bar mark spanning x → x2', () => {
expect(spec.mark?.type).toBe('bar');
expect(spec.encoding?.x?.field).toBe('start');
@@ -62,6 +100,35 @@ describe('Gantt chart', () => {
it('colors bars by phase', () => {
expect(spec.encoding?.color?.field).toBe('phase');
});
+
+ it('applies task height, corners, and interval labels while retaining start order', () => {
+ const input = toInput(genGanttTests()[0]) as any;
+ input.chart_spec.chartProperties = {
+ taskHeight: 55,
+ cornerRadius: 6,
+ intervalLabels: true,
+ };
+ const configured = assembleVegaLite(input) as any;
+ const bar = configured.layer[0];
+ const labels = configured.layer[1];
+
+ expect(bar.mark).toMatchObject({ type: 'bar', height: { band: 0.55 }, cornerRadius: 6 });
+ expect(bar.encoding.y.sort).toMatchObject({ field: 'start', order: 'ascending' });
+ expect(labels.mark.type).toBe('text');
+ expect(labels.encoding.y.axis).not.toBeNull();
+ expect(labels.encoding.text.field).toBe('__gantt_label');
+ expect(configured.transform.some((t: any) => t.as === '__gantt_label')).toBe(true);
+ expect(bar.encoding.x.scale.padding).toBeGreaterThanOrEqual(40);
+ });
+
+ it('uses input order as the tie-breaker for equal starts', () => {
+ const rows = sortGanttRows([
+ { task: 'Second', start: 10, end: 15, inputIndex: 1 },
+ { task: 'First', start: 10, end: 20, inputIndex: 0 },
+ { task: 'Later', start: 20, end: 25, inputIndex: 2 },
+ ]);
+ expect(rows.map((row) => row.task)).toEqual(['First', 'Second', 'Later']);
+ });
});
describe('Bullet chart', () => {
diff --git a/packages/flint-js/tests/pivot.test.ts b/packages/flint-js/tests/pivot.test.ts
index c3860874..b6880ffc 100644
--- a/packages/flint-js/tests/pivot.test.ts
+++ b/packages/flint-js/tests/pivot.test.ts
@@ -7,10 +7,18 @@ import {
assembleECharts,
assembleChartjs,
getChartPivot,
+ getChartTransform,
getEChartsPivot,
getChartjsPivot,
} from '../src';
-import { computePivot, applyPivot } from '../src/core/pivot';
+import {
+ computePivot,
+ applyPivot,
+ applyTransform,
+ computeArrangeStates,
+ computeChartTypeStates,
+} from '../src/core/pivot';
+import { CHART_TRANSITIONS, getChartTransitions } from '../src/core/chart-transitions';
import { barChartDef } from '../src/vegalite/templates/bar';
import { groupedBarChartDef, stackedBarChartDef, histogramDef } from '../src/vegalite/templates/bar';
import { lineChartDef } from '../src/vegalite/templates/line';
@@ -36,16 +44,130 @@ const BAR_ENC = {
};
describe('computePivot — enumeration', () => {
+ it('augments a color series with mutually exclusive facet identity states', () => {
+ const comp = computeArrangeStates(barChartDef, BAR_ENC, BAR_DATA)!;
+ expect(comp.ids).toContain('augment:column');
+ expect(comp.ids).not.toContain('augment:row');
+ expect(comp.labels[comp.ids.indexOf('augment:column')]).toBe('Color + Columns');
+ expect(comp.statesById['augment:column'].column.field).toBe('segment');
+ expect(comp.statesById['augment:column'].color).toBeUndefined();
+ expect(comp.augmentationById['augment:column']).toMatchObject({
+ kind: 'facet-identity',
+ sourceChannel: 'color',
+ facetChannel: 'column',
+ colorEncoding: { field: 'segment', type: 'nominal' },
+ });
+ expect(comp.ids.some(id => id.includes('augment:column|augment:row'))).toBe(false);
+ });
+
+ it('prefers row augmentation for a horizontal bar', () => {
+ const enc = {
+ x: { field: 'sales', type: 'quantitative' as const },
+ y: { field: 'region', type: 'nominal' as const },
+ color: { field: 'segment', type: 'nominal' as const },
+ };
+ const comp = computeArrangeStates(barChartDef, enc, BAR_DATA)!;
+ expect(comp.ids).toContain('augment:row');
+ expect(comp.ids).not.toContain('augment:column');
+ });
+
+ it('revalidates facet preference after composing an orientation flip', () => {
+ const comp = computeArrangeStates(barChartDef, BAR_ENC, BAR_DATA)!;
+ expect(comp.ids).not.toContain('augment:column|flip:x-y');
+ expect(comp.ids).toContain('flip:x-y|augment:row');
+ expect(comp.augmentationById['flip:x-y|augment:row']?.facetChannel).toBe('row');
+ });
+
+ it('defaults to column augmentation without a discrete domain axis', () => {
+ const enc = {
+ x: { field: 'sales', type: 'quantitative' as const },
+ y: { field: 'profit', type: 'quantitative' as const },
+ color: { field: 'segment', type: 'nominal' as const },
+ };
+ const data = BAR_DATA.map((row, index) => ({ ...row, profit: index + 1 }));
+ const comp = computeArrangeStates(scatterPlotDef, enc, data)!;
+ expect(comp.ids).toContain('augment:column');
+ expect(comp.ids).not.toContain('augment:row');
+ });
+
+ it('only shifts an authored facet toward the preferred direction', () => {
+ const data = BAR_DATA.map((row, index) => ({ ...row, market: index % 2 ? 'East' : 'West' }));
+ const verticalWithRow = computeArrangeStates(barChartDef, {
+ x: { field: 'region', type: 'nominal' },
+ y: { field: 'sales', type: 'quantitative' },
+ row: { field: 'market', type: 'nominal' },
+ }, data)!;
+ expect(verticalWithRow.ids).toContain('series:column');
+
+ const horizontalWithColumn = computeArrangeStates(barChartDef, {
+ x: { field: 'sales', type: 'quantitative' },
+ y: { field: 'region', type: 'nominal' },
+ column: { field: 'market', type: 'nominal' },
+ }, data)!;
+ expect(horizontalWithColumn.ids).toContain('series:row');
+
+ const verticalWithColumn = computeArrangeStates(barChartDef, {
+ x: { field: 'region', type: 'nominal' },
+ y: { field: 'sales', type: 'quantitative' },
+ column: { field: 'market', type: 'nominal' },
+ }, data)!;
+ expect(verticalWithColumn.ids).not.toContain('series:row');
+ });
+
+ it('materializes facet identity color after structural bar layout', () => {
+ const spec = assembleVegaLite({
+ data: { values: BAR_DATA },
+ semantic_types: BAR_SEMANTIC,
+ chart_spec: {
+ chartType: 'Bar Chart',
+ encodings: BAR_ENC,
+ chartProperties: { arrange: 'augment:column' },
+ },
+ });
+ expect(spec.encoding.facet.field).toBe('segment');
+ expect(spec.encoding.color.field).toBe('segment');
+ expect(spec.encoding.y.stack).toBeNull();
+ });
+
+ it.each([
+ ['Grouped Bar Chart', { x: 'region', y: 'sales', group: 'segment' }],
+ ['Line Chart', { x: 'region', y: 'sales', color: 'segment' }],
+ ['Scatter Plot', { x: 'sales', y: 'profit', color: 'segment' }],
+ ])('materializes facet identity color for %s', (chartType, encodings) => {
+ const data = BAR_DATA.map((row, index) => ({ ...row, profit: index + 1 }));
+ const spec = assembleVegaLite({
+ data: { values: data },
+ semantic_types: { ...BAR_SEMANTIC, profit: 'Quantity' },
+ chart_spec: {
+ chartType,
+ encodings,
+ chartProperties: { arrange: 'augment:column' },
+ },
+ });
+ const marks = spec.spec?.encoding ?? spec.encoding;
+ const facet = spec.facet ?? spec.encoding?.facet ?? spec.encoding?.column;
+ expect(facet.field).toBe('segment');
+ expect(marks.color.field).toBe('segment');
+ expect(marks.xOffset).toBeUndefined();
+ });
+
+ it('reserves the group channel for Grouped Bar', () => {
+ expect(barChartDef.channels).not.toContain('group');
+ expect(groupedBarChartDef.channels).toContain('group');
+ });
+
it('bar chart exposes default + orientation + role + series routing states', () => {
const comp = computePivot(barChartDef, BAR_ENC, BAR_DATA);
expect(comp).not.toBeNull();
- // series field is on color (stacked); routes to group / column / row. The
+ // series field is on color; plain Bar can route it to column / row facets.
+ // Group routing belongs to the explicit Grouped Bar chart type. The
// orbit is enumerated at runtime and also composes these generators (e.g.
- // orient · series:*), so assert the single-generator states are all present
+ // orient · augment:*), so assert the single-generator states are all present
// rather than pinning an exact flat list.
- for (const id of ['default', 'flip:x-y', 'swap:x-color', 'series:group', 'series:column', 'series:row']) {
+ for (const id of ['default', 'flip:x-y', 'swap:x-color', 'augment:column', 'augment:row']) {
expect(comp!.ids).toContain(id);
}
+ expect(comp!.ids).not.toContain('series:group');
expect(comp!.ids[0]).toBe('default');
// composition shows up as multi-step path ids.
expect(comp!.ids.some(id => id.includes('|'))).toBe(true);
@@ -66,19 +188,17 @@ describe('computePivot — enumeration', () => {
expect(role.y.field).toBe('sales');
});
- it('series routing moves color onto group (grouped), column and row (facets)', () => {
+ it('series augmentation retains color identity across column and row facets', () => {
const comp = computePivot(barChartDef, BAR_ENC, BAR_DATA)!;
- const grouped = comp.statesById['series:group'];
- expect(grouped.group.field).toBe('segment');
- expect(grouped.color).toBeUndefined();
- const cols = comp.statesById['series:column'];
+ expect(comp.statesById['series:group']).toBeUndefined();
+ const cols = comp.statesById['augment:column'];
expect(cols.column.field).toBe('segment');
expect(cols.color).toBeUndefined();
- const rows = comp.statesById['series:row'];
+ const rows = comp.statesById['augment:row'];
expect(rows.row.field).toBe('segment');
});
- it('routes a series authored on column back to color/group/row', () => {
+ it('routes a series authored on column back to color/row, not group', () => {
const enc = {
x: { field: 'segment', type: 'nominal' as const },
y: { field: 'sales', type: 'quantitative' as const },
@@ -86,10 +206,46 @@ describe('computePivot — enumeration', () => {
};
const comp = computePivot(barChartDef, enc, BAR_DATA)!;
expect(comp.ids).toContain('series:color');
- expect(comp.ids).toContain('series:group');
+ expect(comp.ids).not.toContain('series:group');
expect(comp.ids).toContain('series:row');
expect(comp.statesById['series:color'].color.field).toBe('region');
expect(comp.statesById['series:color'].column).toBeUndefined();
+ expect(comp.statesById['series:row'].row.field).toBe('region');
+ });
+
+ it('separates facet shifting from augmentation when color and column are occupied', () => {
+ const enc = {
+ x: { field: 'region', type: 'nominal' as const },
+ y: { field: 'sales', type: 'quantitative' as const },
+ color: { field: 'segment', type: 'nominal' as const },
+ column: { field: 'market', type: 'nominal' as const },
+ };
+ const data = BAR_DATA.map((row, index) => ({ ...row, market: index % 2 ? 'East' : 'West' }));
+ const comp = computePivot(barChartDef, enc, data)!;
+
+ expect(comp.ids).not.toContain('augment:column');
+ expect(comp.ids).not.toContain('series:column');
+ expect(comp.ids).toContain('augment:row');
+ expect(comp.labels[comp.ids.indexOf('augment:row')]).toBe('Color + Rows');
+ expect(comp.statesById['augment:row'].column.field).toBe('market');
+ expect(comp.augmentationById['augment:row']?.colorEncoding.field).toBe('segment');
+ expect(comp.ids).toContain('series:row');
+ expect(comp.labels[comp.ids.indexOf('series:row')]).toBe('Columns ⇄ Rows');
+ expect(comp.statesById['series:row'].row.field).toBe('market');
+ expect(comp.statesById['series:row'].color.field).toBe('segment');
+
+ const spec = assembleVegaLite({
+ data: { values: data },
+ semantic_types: { ...BAR_SEMANTIC, market: 'Category' },
+ chart_spec: {
+ chartType: 'Bar Chart',
+ encodings: enc,
+ chartProperties: { arrange: 'augment:row' },
+ },
+ });
+ expect(spec.encoding.facet.field).toBe('market');
+ expect(spec.encoding.row).toBeUndefined();
+ expect(spec.encoding.color.field).toBe('segment');
});
it('returns null when the template declares no pivot', () => {
@@ -146,17 +302,20 @@ describe('computePivot — gating', () => {
sales: i,
}));
const comp = computePivot(barChartDef, BAR_ENC, wide)!;
- expect(comp.ids).not.toContain('series:column');
- expect(comp.ids).not.toContain('series:row');
+ expect(comp.ids).not.toContain('augment:column');
+ expect(comp.ids).not.toContain('augment:row');
});
- it('scatter with only two measures exposes only orientation', () => {
+ it('scatter with two measures exposes orientation + a regression transition', () => {
const enc = {
x: { field: 'a', type: 'quantitative' as const },
y: { field: 'b', type: 'quantitative' as const },
};
const comp = computePivot(scatterPlotDef, enc, BAR_DATA)!;
- expect(comp.ids).toEqual(['default', 'flip:x-y']);
+ expect(comp.ids).toContain('flip:x-y');
+ expect(comp.ids).toContain('type:Regression');
+ // No discrete role swap without a color/size field.
+ expect(comp.ids.some((id) => id.startsWith('swap:'))).toBe(false);
});
it('scatter swaps a quantitative position field with a quantitative color', () => {
@@ -166,12 +325,12 @@ describe('computePivot — gating', () => {
color: { field: 'c', type: 'quantitative' as const },
};
const comp = computePivot(scatterPlotDef, enc, BAR_DATA)!;
- // identity + axis swap + (X↔color, Y↔color), plus their compositions with
- // the orientation swap (the runtime orbit). No discrete role swap.
- expect(comp.ids).toEqual([
- 'default', 'flip:x-y', 'swap:x-color', 'swap:y-color',
- 'flip:x-y|swap:x-color', 'flip:x-y|swap:y-color',
- ]);
+ // identity + axis swap + (X↔color, Y↔color) + the Regression transition,
+ // plus their compositions (the runtime orbit). No discrete role swap.
+ expect(comp.ids).toContain('flip:x-y');
+ expect(comp.ids).toContain('swap:x-color');
+ expect(comp.ids).toContain('swap:y-color');
+ expect(comp.ids).toContain('type:Regression');
// Y↔color exchanges the field on Y with the field on color (type-preserving).
const yColor = comp.statesById['swap:y-color'];
expect(yColor.y.field).toBe('c');
@@ -311,7 +470,7 @@ describe('backend pivot parity — ECharts and Chart.js', () => {
chartType: 'Stacked Bar Chart',
encodings: { x: 'region', y: 'sales', color: 'segment' },
baseSize: { width: 400, height: 300 },
- chartProperties: { pivot: 'series:column', stackMode: backend === 'vegalite' ? 'center' : 'normalize' },
+ chartProperties: { pivot: 'augment:column', stackMode: backend === 'vegalite' ? 'center' : 'normalize' },
},
});
@@ -372,9 +531,9 @@ describe('backend pivot parity — ECharts and Chart.js', () => {
it('does not keep stack offsets when a stacked series is routed to facets', () => {
const vl = assembleVegaLite(stackedFacetInput('vegalite')) as any;
- expect(vl.encoding.color).toBeUndefined();
+ expect(vl.encoding.color.field).toBe('segment');
expect(vl.encoding.facet?.field ?? vl.encoding.column?.field).toBe('segment');
- expect(vl.encoding.y.stack).toBeUndefined();
+ expect(vl.encoding.y.stack).toBeNull();
const ec = assembleECharts(stackedFacetInput('echarts')) as any;
for (const series of ec.series ?? []) {
@@ -505,7 +664,11 @@ describe('computePivot — chart-type transitions', () => {
color: { field: 'a', type: 'quantitative' as const }, // spilled measure
};
const comp = computePivot(stripPlotDef, enc, SCATTER_DATA)!;
- expect(comp.ids).toEqual(['default', 'type:Scatter Plot']);
+ // A strip plot bridges to the two-measure family (Scatter) AND the
+ // distribution family (Box / Violin).
+ expect(comp.ids).toContain('type:Scatter Plot');
+ expect(comp.ids).toContain('type:Boxplot');
+ expect(comp.ids).toContain('type:Violin Plot');
const st = comp.statesById['type:Scatter Plot'];
expect(st.x.field).toBe('a'); // color measure → x
expect(st.color.field).toBe('c'); // displaced category → color series
@@ -553,12 +716,12 @@ describe('computePivot — runtime orbit (composition, dedup, validity)', () =>
const comp = computePivot(scatterPlotDef, SCATTER_ENC, SCATTER_DATA, vlGetTemplateDef)!;
// single-generator states are present...
expect(comp.ids).toContain('flip:x-y');
- expect(comp.ids).toContain('series:column');
+ expect(comp.ids).toContain('augment:column');
// ...and so are their compositions, with a composed operator label.
- expect(comp.ids).toContain('flip:x-y|series:column');
- const i = comp.ids.indexOf('flip:x-y|series:column');
- expect(comp.labels[i]).toBe('τ_x↔y · γ_→column');
- const enc = comp.statesById['flip:x-y|series:column'];
+ expect(comp.ids).toContain('flip:x-y|augment:column');
+ const i = comp.ids.indexOf('flip:x-y|augment:column');
+ expect(comp.labels[i]).toBe('X ⇄ Y · Color + Columns');
+ const enc = comp.statesById['flip:x-y|augment:column'];
expect(enc.x.field).toBe('b'); // orientation swapped the axes
expect(enc.y.field).toBe('a');
expect(enc.column.field).toBe('c'); // series routed to a facet
@@ -643,21 +806,45 @@ describe('computePivot — Tier-1 templates (lollipop, area, histogram, density)
const comp = computePivot(lollipopChartDef, BAR_ENC, BAR_DATA)!;
expect(comp.ids).toContain('flip:x-y');
expect(comp.ids).toContain('swap:x-color');
- expect(comp.ids).toContain('series:column');
- expect(comp.ids).toContain('series:row');
+ expect(comp.ids).toContain('augment:column');
+ expect(comp.ids).toContain('augment:row');
});
- it('lollipop does NOT offer a chart-type transition to a bar', () => {
+ it('lollipop offers a chart-type transition to a bar', () => {
const comp = computePivot(lollipopChartDef, BAR_ENC, BAR_DATA, vlGetTemplateDef)!;
- expect(comp.ids.some(id => id.includes('type:Bar Chart'))).toBe(false);
+ expect(comp.ids.some(id => id.includes('type:Bar Chart'))).toBe(true);
+ });
+
+ it('horizontal lollipop (temporal domain) anchors the stem from x=0, not the temporal baseline', () => {
+ const input = {
+ data: {
+ values: [
+ { t: '2020-01-01', v: 10 },
+ { t: '2021-01-01', v: 20 },
+ { t: '2022-01-01', v: 15 },
+ ],
+ },
+ semantic_types: { t: 'Date', v: 'Quantity' },
+ chart_spec: {
+ chartType: 'Lollipop Chart',
+ encodings: { x: 'v', y: 't' },
+ baseSize: { width: 400, height: 300 },
+ },
+ };
+ const spec = assembleVegaLite(input);
+ const rule = spec.layer[0].encoding;
+ // Measure is on x → horizontal stem anchored at x=0, NOT a vertical stem to
+ // the temporal baseline (the bug: temporal was mis-classified as the measure).
+ expect(rule.x2).toEqual({ datum: 0 });
+ expect(rule.y2).toBeUndefined();
});
- it('bar does NOT offer a chart-type transition to a lollipop', () => {
+ it('bar offers a chart-type transition to a lollipop', () => {
const comp = computePivot(barChartDef, BAR_ENC, BAR_DATA, vlGetTemplateDef)!;
- expect(comp.ids.some(id => id.includes('type:Lollipop Chart'))).toBe(false);
+ expect(comp.ids.some(id => id.includes('type:Lollipop Chart'))).toBe(true);
});
- it('area offers series routing but no orientation or chart-type transition', () => {
+ it('area offers series routing + a line transition but no orientation flip', () => {
const enc = {
x: { field: 'day', type: 'temporal' as const },
y: { field: 'sales', type: 'quantitative' as const },
@@ -666,26 +853,26 @@ describe('computePivot — Tier-1 templates (lollipop, area, histogram, density)
const comp = computePivot(areaChartDef, enc, BAR_DATA, vlGetTemplateDef)!;
// No vertical area: x is pinned (no orientation flip).
expect(comp.ids).not.toContain('flip:x-y');
- expect(comp.ids).toContain('series:column');
- expect(comp.ids).toContain('series:row');
- // No θ edge to a line.
- expect(comp.ids.some(id => id.includes('type:Line Chart'))).toBe(false);
+ expect(comp.ids).toContain('augment:column');
+ expect(comp.ids).toContain('augment:row');
+ // θ edge to a line (same T×M signature).
+ expect(comp.ids.some(id => id.includes('type:Line Chart'))).toBe(true);
});
- it('line does NOT offer a chart-type transition to an area', () => {
+ it('line offers a chart-type transition to an area (non-negative values)', () => {
const enc = {
x: { field: 'day', type: 'temporal' as const },
y: { field: 'sales', type: 'quantitative' as const },
color: { field: 'segment', type: 'nominal' as const },
};
const comp = computePivot(lineChartDef, enc, BAR_DATA, vlGetTemplateDef)!;
- expect(comp.ids.some(id => id.includes('type:Area Chart'))).toBe(false);
+ expect(comp.ids.some(id => id.includes('type:Area Chart'))).toBe(true);
});
it('histogram routes a series to facets and offers a density transition', () => {
const comp = computePivot(histogramDef, DIST_ENC, DIST_DATA)!;
- expect(comp.ids).toContain('series:column');
- expect(comp.ids).toContain('series:row');
+ expect(comp.ids).toContain('augment:column');
+ expect(comp.ids).toContain('augment:row');
expect(comp.ids).toContain('type:Density Plot');
expect(comp.chartTypeById['type:Density Plot']).toBe('Density Plot');
// The transition re-views the same field; nothing is re-routed.
@@ -694,8 +881,8 @@ describe('computePivot — Tier-1 templates (lollipop, area, histogram, density)
it('density routes a series to facets and offers a histogram transition', () => {
const comp = computePivot(densityPlotDef, DIST_ENC, DIST_DATA)!;
- expect(comp.ids).toContain('series:column');
- expect(comp.ids).toContain('series:row');
+ expect(comp.ids).toContain('augment:column');
+ expect(comp.ids).toContain('augment:row');
expect(comp.ids).toContain('type:Histogram');
expect(comp.chartTypeById['type:Histogram']).toBe('Histogram');
expect(comp.statesById['type:Histogram'].x.field).toBe('score');
@@ -710,3 +897,337 @@ describe('computePivot — Tier-1 templates (lollipop, area, histogram, density)
});
});
+// ─── Factored two-control model (chart-transform-two-axes.md) ────────────────
+
+const SCATTER_ENC = {
+ x: { field: 'a', type: 'quantitative' as const },
+ y: { field: 'b', type: 'quantitative' as const },
+ color: { field: 'c', type: 'nominal' as const },
+};
+const SCATTER_DATA = [
+ { a: 1, b: 2, c: 'X' }, { a: 3, b: 1, c: 'Y' },
+ { a: 2, b: 4, c: 'X' }, { a: 5, b: 3, c: 'Y' },
+];
+
+describe('computeChartTypeStates — Control B (θ only, one hop)', () => {
+ it('scatter (Q,Q,N) exposes default + Strip Plot sibling, labelled by chart name', () => {
+ const comp = computeChartTypeStates(scatterPlotDef, SCATTER_ENC, SCATTER_DATA)!;
+ expect(comp).not.toBeNull();
+ expect(comp.key).toBe('chartType');
+ expect(comp.ids[0]).toBe('default');
+ expect(comp.labels[0]).toBe('Scatter Plot');
+ expect(comp.ids).toContain('type:Strip Plot');
+ expect(comp.labels).toContain('Strip Plot');
+ // one hop only: no composed path ids, no local (τ/σ/γ) ids.
+ expect(comp.ids.every((id) => id === 'default' || id.startsWith('type:'))).toBe(true);
+ expect(comp.ids.some((id) => id.includes('|'))).toBe(false);
+ });
+
+ it('bar (nominal x) exposes only the unconditional Lollipop sibling', () => {
+ const comp = computeChartTypeStates(barChartDef, BAR_ENC, BAR_DATA)!;
+ expect(comp).not.toBeNull();
+ expect(comp.ids).toContain('type:Lollipop Chart');
+ // Line/Area are gated out: region is nominal (not an ordered axis).
+ expect(comp.ids).not.toContain('type:Line Chart');
+ expect(comp.ids).not.toContain('type:Area Chart');
+ });
+
+ it('bar (ordinal x) offers the Line + Area ordered-axis bridge', () => {
+ const enc = {
+ x: { field: 'region', type: 'ordinal' as const },
+ y: { field: 'sales', type: 'quantitative' as const },
+ };
+ const comp = computeChartTypeStates(barChartDef, enc, BAR_DATA)!;
+ expect(comp.ids).toContain('type:Line Chart');
+ expect(comp.ids).toContain('type:Area Chart');
+ });
+
+ it('Scatter → Regression is gated to a clean two-measure scatter', () => {
+ const qq = {
+ x: { field: 'a', type: 'quantitative' as const },
+ y: { field: 'b', type: 'quantitative' as const },
+ };
+ expect(
+ computeChartTypeStates(scatterPlotDef, qq, [{ a: 1, b: 2 }, { a: 3, b: 4 }], vlGetTemplateDef)!.ids,
+ ).toContain('type:Regression');
+
+ // Category x → a regression line is meaningless.
+ const catX = {
+ x: { field: 'c', type: 'nominal' as const },
+ y: { field: 'b', type: 'quantitative' as const },
+ };
+ const catStates = computeChartTypeStates(scatterPlotDef, catX, [{ c: 'X', b: 1 }], vlGetTemplateDef);
+ expect(catStates?.ids ?? []).not.toContain('type:Regression');
+
+ // Bubble (size bound) → keep the trend off a cluttered bubble chart.
+ const bubble = {
+ x: { field: 'a', type: 'quantitative' as const },
+ y: { field: 'b', type: 'quantitative' as const },
+ size: { field: 's', type: 'quantitative' as const },
+ };
+ const sizeStates = computeChartTypeStates(scatterPlotDef, bubble, [{ a: 1, b: 2, s: 3 }], vlGetTemplateDef);
+ expect(sizeStates?.ids ?? []).not.toContain('type:Regression');
+ });
+
+ it('a HORIZONTAL bar → Line re-orients the temporal domain onto x (no vertical line)', () => {
+ // Horizontal bar: the ordered/temporal domain sits on y, the measure on x.
+ const enc = {
+ x: { field: 'sales', type: 'quantitative' as const },
+ y: { field: 'day', type: 'temporal' as const },
+ };
+ const comp = computeChartTypeStates(barChartDef, enc, BAR_DATA)!;
+ const line = comp.statesById['type:Line Chart'];
+ expect(line).toBeDefined();
+ // domain (temporal) must land on x; measure on y — never a vertical line.
+ expect(line.x.field).toBe('day');
+ expect(line.y.field).toBe('sales');
+ });
+});
+
+describe('computeArrangeStates — Control A (τ/σ/γ only, no θ)', () => {
+ it('bar exposes the local group but never a chart-type transition', () => {
+ const comp = computeArrangeStates(barChartDef, BAR_ENC, BAR_DATA)!;
+ expect(comp.key).toBe('arrange');
+ expect(comp.ids[0]).toBe('default');
+ for (const id of ['flip:x-y', 'swap:x-color', 'augment:column']) {
+ expect(comp.ids).toContain(id);
+ }
+ expect(comp.ids).not.toContain('augment:row');
+ expect(comp.ids.some((id) => id.startsWith('type:'))).toBe(false);
+ });
+
+ it('scatter arrange excludes the Strip Plot transition', () => {
+ const comp = computeArrangeStates(scatterPlotDef, SCATTER_ENC, SCATTER_DATA)!;
+ expect(comp.ids).not.toContain('type:Strip Plot');
+ expect(comp.ids).toContain('flip:x-y');
+ });
+});
+
+describe('applyTransform — two independent overrides', () => {
+ it('default: authored view + both surfaces', () => {
+ const { encodings, chartType, surface } = applyTransform(
+ scatterPlotDef, SCATTER_ENC, SCATTER_DATA, undefined, vlGetTemplateDef,
+ );
+ expect(chartType).toBeUndefined();
+ expect(encodings.x.field).toBe('a');
+ expect(surface.chartType!.index).toBe(0);
+ expect(surface.arrange!.index).toBe(0);
+ });
+
+ it('Control B: a chart-type override re-routes + re-selects the sibling', () => {
+ const { chartType, encodings } = applyTransform(
+ scatterPlotDef, SCATTER_ENC, SCATTER_DATA, { chartType: 'type:Strip Plot' }, vlGetTemplateDef,
+ );
+ expect(chartType).toBe('Strip Plot');
+ // series (color=c) moved onto x; measure a spilled to color.
+ expect(encodings.x.field).toBe('c');
+ });
+
+ it('Control A: an arrange override swaps axes on the current object', () => {
+ const { chartType, encodings } = applyTransform(
+ barChartDef, BAR_ENC, BAR_DATA, { arrange: 'flip:x-y' }, vlGetTemplateDef,
+ );
+ expect(chartType).toBeUndefined();
+ expect(encodings.x.field).toBe('sales');
+ expect(encodings.y.field).toBe('region');
+ });
+
+ it('reset: a stale arrange id falls back to identity after a θ switch', () => {
+ const { chartType, encodings } = applyTransform(
+ scatterPlotDef, SCATTER_ENC, SCATTER_DATA,
+ { chartType: 'type:Strip Plot', arrange: 'flip:x-y' }, vlGetTemplateDef,
+ );
+ expect(chartType).toBe('Strip Plot');
+ // If 'flip:x-y' is not a Strip Plot arrange state, it is ignored (reset to
+ // identity) rather than corrupting the encoding — x stays the series field.
+ expect(encodings.x.field).toBe('c');
+ });
+
+ it('legacy shim: a composed `pivot` id splits into chartType + arrange', () => {
+ const local = applyTransform(
+ barChartDef, BAR_ENC, BAR_DATA, { pivot: 'flip:x-y' }, vlGetTemplateDef,
+ );
+ expect(local.encodings.x.field).toBe('sales');
+
+ const theta = applyTransform(
+ scatterPlotDef, SCATTER_ENC, SCATTER_DATA, { pivot: 'type:Strip Plot' }, vlGetTemplateDef,
+ );
+ expect(theta.chartType).toBe('Strip Plot');
+ });
+});
+
+describe('getChartTransform — end-to-end through assembleVegaLite', () => {
+ const scatterInput = (props?: Record) => ({
+ data: { values: SCATTER_DATA },
+ semantic_types: { a: 'Quantity', b: 'Quantity', c: 'Category' },
+ chart_spec: {
+ chartType: 'Scatter Plot',
+ encodings: { x: 'a', y: 'b', color: 'c' },
+ baseSize: { width: 400, height: 300 },
+ ...(props ? { chartProperties: props } : {}),
+ },
+ });
+
+ it('surfaces both controls with active indices', () => {
+ const surface = getChartTransform(scatterInput())!;
+ expect(surface).toBeDefined();
+ expect(surface.chartType!.ids).toContain('type:Strip Plot');
+ expect(surface.chartType!.index).toBe(0);
+ expect(surface.arrange!.index).toBe(0);
+ });
+
+ it('a chart-type override re-dispatches the assembled spec to the sibling', () => {
+ const base = assembleVegaLite(scatterInput());
+ const strip = assembleVegaLite(scatterInput({ chartType: 'type:Strip Plot' }));
+ // The strip plot puts the category on x; the scatter keeps the measure.
+ expect(base.encoding.x.field).toBe('a');
+ expect(strip.encoding.x.field).toBe('c');
+ });
+
+ it('backward-compat: getChartPivot still surfaces the legacy composed orbit', () => {
+ const surface = getChartPivot(scatterInput())!;
+ expect(surface).toBeDefined();
+ expect(surface.ids[0]).toBe('default');
+ expect(surface.ids).toContain('type:Strip Plot');
+ });
+
+ it('a Pyramid Chart (no local pivot def) still offers a Grouped Bar sibling', () => {
+ const input = {
+ data: {
+ values: [
+ { age: '0-9', gender: 'Male', pop: 100 },
+ { age: '0-9', gender: 'Female', pop: 110 },
+ { age: '10-19', gender: 'Male', pop: 90 },
+ { age: '10-19', gender: 'Female', pop: 95 },
+ ],
+ },
+ semantic_types: { age: 'Category', gender: 'Category', pop: 'Quantity' },
+ chart_spec: {
+ chartType: 'Pyramid Chart',
+ encodings: { y: 'age', x: 'pop', color: 'gender' },
+ baseSize: { width: 400, height: 300 },
+ },
+ };
+ const surface = getChartTransform(input)!;
+ expect(surface.chartType).toBeDefined();
+ expect(surface.chartType!.ids).toContain('type:Grouped Bar Chart');
+ // Pyramid has no local τ/σ/γ group (fixed orientation).
+ expect(surface.arrange).toBeUndefined();
+ });
+
+ it('a multi-series Line offers a Sparkline sibling; a single-series Line does not', () => {
+ const withSeries = {
+ data: {
+ values: [
+ { t: '2020-01-01', region: 'US', v: 10 },
+ { t: '2021-01-01', region: 'US', v: 20 },
+ { t: '2020-01-01', region: 'CN', v: 8 },
+ { t: '2021-01-01', region: 'CN', v: 14 },
+ ],
+ },
+ semantic_types: { t: 'Date', region: 'Category', v: 'Quantity' },
+ chart_spec: {
+ chartType: 'Line Chart',
+ encodings: { x: 't', y: 'v', color: 'region' },
+ baseSize: { width: 400, height: 300 },
+ },
+ };
+ expect(getChartTransform(withSeries)!.chartType!.ids).toContain('type:Sparkline');
+
+ const noSeries = {
+ data: { values: [{ t: '2020-01-01', v: 10 }, { t: '2021-01-01', v: 20 }] },
+ semantic_types: { t: 'Date', v: 'Quantity' },
+ chart_spec: {
+ chartType: 'Line Chart',
+ encodings: { x: 't', y: 'v' },
+ baseSize: { width: 400, height: 300 },
+ },
+ };
+ const s2 = getChartTransform(noSeries);
+ expect(s2?.chartType?.ids ?? []).not.toContain('type:Sparkline');
+ });
+
+ it('a FACETED line (series on column) routes the series onto color for Sparkline', () => {
+ const enc = {
+ x: { field: 'm', type: 'temporal' as const },
+ y: { field: 'rev', type: 'quantitative' as const },
+ column: { field: 'region', type: 'nominal' as const },
+ };
+ const data = [
+ { m: '2020-01-01', region: 'North', rev: 73 },
+ { m: '2020-02-01', region: 'North', rev: 61 },
+ { m: '2020-01-01', region: 'South', rev: 50 },
+ { m: '2020-02-01', region: 'South', rev: 55 },
+ ];
+ const comp = computeChartTypeStates(lineChartDef, enc, data, vlGetTemplateDef)!;
+ const spark = comp.statesById['type:Sparkline'];
+ expect(spark).toBeDefined();
+ // The facet series was moved to color (where Sparkline expects it); column cleared.
+ expect(spark.color.field).toBe('region');
+ expect(spark.column).toBeUndefined();
+ });
+
+ it('Regression (a scatter sibling) keeps an Arrange control after the θ switch', () => {
+ const input = {
+ data: {
+ values: [
+ { Height: 165, Weight: 90, Group: 'A' },
+ { Height: 159, Weight: 70, Group: 'A' },
+ { Height: 180, Weight: 85, Group: 'B' },
+ { Height: 175, Weight: 78, Group: 'B' },
+ ],
+ },
+ semantic_types: { Height: 'Value', Weight: 'Value', Group: 'Category' },
+ chart_spec: {
+ chartType: 'Scatter Plot',
+ encodings: { x: 'Height', y: 'Weight', column: 'Group' },
+ baseSize: { width: 400, height: 300 },
+ chartProperties: { chartType: 'type:Regression' },
+ },
+ };
+ const surface = getChartTransform(input)!;
+ expect(surface.chartType!.index).toBeGreaterThan(0); // now on Regression
+ // Control A is available on the Regression (scatter-like local group).
+ expect(surface.arrange).toBeDefined();
+ expect(surface.arrange!.ids).toContain('flip:x-y');
+ expect(surface.arrange!.ids).toContain('series:color');
+ });
+});
+
+
+// ─── Central transition registry validation ─────────────────────────────────
+
+describe('CHART_TRANSITIONS — registry integrity', () => {
+ it('every transition target is a registered Vega-Lite template', () => {
+ for (const [from, edges] of Object.entries(CHART_TRANSITIONS)) {
+ expect(vlGetTemplateDef(from), `source '${from}' should be a real chart type`).toBeDefined();
+ for (const t of edges) {
+ expect(
+ vlGetTemplateDef(t.to),
+ `'${from}' → '${t.to}' target must exist as a VL template`,
+ ).toBeDefined();
+ }
+ }
+ });
+
+ it('no edge points a chart type back at itself', () => {
+ for (const [from, edges] of Object.entries(CHART_TRANSITIONS)) {
+ for (const t of edges) {
+ expect(t.to, `'${from}' should not transition to itself`).not.toBe(from);
+ }
+ }
+ });
+
+ it('every edge is reversible (A→B implies B→A) so a transform round-trips', () => {
+ // Intentional one-directional edges: unwinding a stack back to a plain trend
+ // is a safe read, but the forward hop is a bigger semantic leap we don't offer.
+ const ONE_WAY = new Set(['Streamgraph→Line Chart']);
+ for (const [from, edges] of Object.entries(CHART_TRANSITIONS)) {
+ for (const t of edges) {
+ if (ONE_WAY.has(`${from}→${t.to}`)) continue;
+ const back = getChartTransitions(t.to).some((r) => r.to === from);
+ expect(back, `'${t.to}' should declare a reverse edge to '${from}'`).toBe(true);
+ }
+ }
+ });
+});
diff --git a/packages/flint-js/tests/sparkline.test.ts b/packages/flint-js/tests/sparkline.test.ts
index 2d41bcbe..65e2adcf 100644
--- a/packages/flint-js/tests/sparkline.test.ts
+++ b/packages/flint-js/tests/sparkline.test.ts
@@ -106,15 +106,13 @@ describe('Vega-Lite Sparkline', () => {
expect(avgPanel(spec).spec.encoding.color.field).toBe('Metric');
});
- it('self-scales each strip by default, but honors a shared Y when requested', () => {
+ it('always self-scales each strip and does not expose a Y-scale option', () => {
const auto = assembleVegaLite(toInput(byTitle(cases, MULTI))) as any;
- // Each strip owns its own y scale by default so the trace fills the band and
- // lines up with its centered category label and value.
expect(trendPanel(auto).resolve?.scale?.y).toBe('independent');
+ expect(auto._options).not.toContainEqual(expect.objectContaining({ key: 'independentYAxis' }));
- const shared = assembleVegaLite(toInput(byTitle(cases, MULTI), { independentYAxis: false })) as any;
- // Opting into a shared scale keeps every row comparable by absolute level.
- expect(trendPanel(shared).resolve?.scale?.y).toBe('shared');
+ const legacyOverride = assembleVegaLite(toInput(byTitle(cases, MULTI), { independentYAxis: false })) as any;
+ expect(trendPanel(legacyOverride).resolve?.scale?.y).toBe('independent');
});
it('honors the interpolate (curve) property', () => {
@@ -151,6 +149,36 @@ describe('Vega-Lite Sparkline', () => {
expect(avgPanel(spec).spec.encoding.text).toMatchObject({ field: 'flintSparkAvg', format: '.3~s' });
});
+ it('preserves semantic currency formatting in the average column', () => {
+ const spec = assembleVegaLite({
+ data: {
+ values: [
+ { month: '2025-01-01', item: 'Bananas', price: 0.59 },
+ { month: '2025-02-01', item: 'Bananas', price: 0.61 },
+ ],
+ },
+ semantic_types: {
+ month: 'YearMonth',
+ item: 'Category',
+ price: { semanticType: 'Price', unit: 'USD' },
+ },
+ chart_spec: {
+ chartType: 'Sparkline',
+ encodings: { x: 'month', y: 'price', color: 'item' },
+ baseSize: { width: 480, height: 360 },
+ },
+ }) as any;
+
+ expect(avgPanel(spec).spec.transform).toContainEqual({
+ calculate: "'$' + format(datum.flintSparkAvg, ',.2f')",
+ as: 'flintSparkAvgFormatted',
+ });
+ expect(avgPanel(spec).spec.encoding.text).toEqual({
+ field: 'flintSparkAvgFormatted',
+ type: 'nominal',
+ });
+ });
+
it('uses a compact base sparkline width by default and honors a tuned width', () => {
const tc = byTitle(cases, MULTI);
const auto = assembleVegaLite(toInput(tc)) as any;
diff --git a/packages/flint-js/tests/vegalite-format.test.ts b/packages/flint-js/tests/vegalite-format.test.ts
new file mode 100644
index 00000000..ef9a0a20
--- /dev/null
+++ b/packages/flint-js/tests/vegalite-format.test.ts
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { describe, expect, it } from 'vitest';
+import { formatSpecToLabelExpr, formatSpecToVegaExpr } from '../src/vegalite/format';
+
+describe('Vega-Lite semantic formatting', () => {
+ it('formats an arbitrary derived value with semantic affixes', () => {
+ expect(formatSpecToVegaExpr(
+ { pattern: ',.2f', prefix: '$' },
+ 'datum.flintSparkAvg',
+ )).toBe("'$' + format(datum.flintSparkAvg, ',.2f')");
+ });
+
+ it('supports affixes without forcing a number pattern', () => {
+ expect(formatSpecToVegaExpr(
+ { suffix: ' kg' },
+ 'datum["weight"]',
+ )).toBe("datum[\"weight\"] + ' kg'");
+ });
+
+ it('escapes affixes embedded in Vega string literals', () => {
+ expect(formatSpecToVegaExpr(
+ { pattern: ',.1f', prefix: "owner's ", suffix: '\\unit' },
+ 'datum.value',
+ )).toBe("'owner\\'s ' + format(datum.value, ',.1f') + '\\\\unit'");
+ });
+
+ it('applies abbreviation to an arbitrary value expression', () => {
+ const expr = formatSpecToVegaExpr(
+ { abbreviate: true, prefix: '$' },
+ 'datum.total',
+ );
+ expect(expr).toContain("'$' + (abs(datum.total) >= 1e12");
+ expect(expr).toContain("format(datum.total / 1e3, '~g') + 'K'");
+ expect(expr).toContain("format(datum.total, ',')");
+ });
+
+ it('leaves a pure axis pattern to Vega-Lite format', () => {
+ expect(formatSpecToLabelExpr({ pattern: ',.2f' })).toBeNull();
+ expect(formatSpecToLabelExpr({ pattern: ',.2f', prefix: '$' }))
+ .toBe("'$' + format(datum.value, ',.2f')");
+ });
+});
\ No newline at end of file
diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md
index e9265dd5..c05dee2d 100644
--- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md
+++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md
@@ -182,7 +182,7 @@ properties"). Required channels are noted.
| `"Ranged Dot Plot"` | x, y, color | dumbbell of two x per category |
| `"Strip Plot"` | x, y, color, size, column, row | jittered points; props `stepWidth`, `pointSize`, `opacity` |
| `"Bar Chart"` | x, y, color, opacity, column, row | one discrete + one measure; prop `cornerRadius` |
-| `"Grouped Bar Chart"` | x, y, group, column, row | `group` = the clustering category |
+| `"Grouped Bar Chart"` | x, y, group, column, row | `group` = the clustering category; prop `dodge` |
| `"Stacked Bar Chart"` | x, y, color, column, row | prop `stackMode` |
| `"Pyramid Chart"` | x, y, color | diverging horizontal bars |
| `"Lollipop Chart"` | x, y, color, column, row | prop `dotSize` |
@@ -190,7 +190,7 @@ properties"). Required channels are noted.
| `"Gantt Chart"` | y, x, x2, color, detail, column, row | x = start, x2 = end |
| `"Bullet Chart"` | y, x, goal, color, column, row | `goal` required (target) |
| `"Histogram"` | x, color, column, row | x = measure to bin; prop `binCount` |
-| `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers` |
+| `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers`, `dodge` |
| `"ECDF Plot"` | x, color, detail, column, row | x = measure; cumulative distribution (step line); prop `showPoints` |
| `"Heatmap"` | x, y, color, column, row | color = the measure |
| `"Line Chart"` | x, y, color, strokeDash, detail, opacity, column, row | props `interpolate`, `showPoints` |
@@ -199,11 +199,11 @@ properties"). Required channels are noted.
| `"Slope Chart"` | x, y, color, detail, column, row | two-period value change; straight segments + end points, one line per category |
| `"Area Chart"` | x, y, color, opacity, column, row | props `interpolate`, `opacity`, `stackMode` |
| `"Range Area Chart"` | x, y, y2, color, column, row | x + y + y2 required; translucent band from `y` (low) to `y2` (high), value axis fits the band (not zero) |
-| `"Violin Plot"` | x, y, color, row | x (category) + y (measure) required; mirrored KDE density per category, prop `bandwidth`; **Vega-Lite only**; `column` is used internally for the per-category panels |
+| `"Violin Plot"` | x, y, color, row | x (category) + y (measure) required; mirrored KDE density per category, prop `bandwidth`; **Vega-Lite only**; a genuine `color` subgroup splits two groups or grids 3+ groups |
| `"Streamgraph"` | x, y, color, column, row | centre-stacked areas |
| `"Density Plot"` | x, color, column, row | prop `bandwidth` |
-| `"Pie Chart"` | size, color, column, row | `size` = slice value (→ angle), `color` = category |
-| `"Rose Chart"` | x, y, color, column, row | polar bars; props `alignment`, `innerRadius`, `padAngle` |
+| `"Pie Chart"` | size, color, column, row | `size` = slice value (→ angle), `color` = category; props `innerRadius`, `sortSlices` |
+| `"Rose Chart"` | x, y, color, column, row | polar bars; props `alignment`, `padAngle`, `sortSlices` |
| `"Radar Chart"` | x, y, color, column, row | props `filled`, `fillOpacity`, `strokeWidth` |
| `"Candlestick Chart"` | x, open, high, low, close, column, row | OHLC all required |
| `"Bar Table"` | y, x, color, column, row | compact bars + value labels |
@@ -218,10 +218,9 @@ category on `x` (or `y`) plus one measure. They differ in how a **second**
category is shown — and each reads that second category from a **different
channel**:
-- `"Bar Chart"` — no second category. One bar per `x` value. A `color`
- encoding just tints the bars (or stacks segments if multiple rows share an
- `x`). It has **no `group` channel**, so a `group` encoding is silently
- ignored.
+- `"Bar Chart"` — use for a single series. When multiple rows share an `x`, a
+ second category on `color` produces stacked segments. For side-by-side bars,
+ use `"Grouped Bar Chart"` with the second category on `group`.
- `"Stacked Bar Chart"` — second category on `color`, drawn as **stacked**
segments within each bar (totals matter). Tune with `stackMode`
(`stacked` / `normalize` / `layered`).
@@ -344,7 +343,8 @@ derived). Values are clamped to the ranges shown.
| Chart type | Property | Type / range (default) | Effect |
|---|---|---|---|
| Bar Chart | `cornerRadius` | 0–15 (0) | Round bar corners (px) |
-| Bar / Area / Stacked Bar | `stackMode` | `stacked` \| `normalize` \| `layered` (unset) | Stacking behavior; `normalize` = 100% |
+| Area / Stacked Bar | `stackMode` | `stacked` \| `normalize` \| `center` \| `layered` (unset) | Stacking behavior; `normalize` = 100%, `center` = streamgraph |
+| Grouped Bar / Boxplot | `dodge` | `auto` \| `local` \| `global` (`auto`) | `local` compacts sparse groups per category; `global` preserves aligned group lanes; leave `auto` unless the user requests one |
| Line / Area / Sparkline | `interpolate` | `linear` \| `monotone` \| `step` \| `step-before` \| `step-after` \| `basis` \| `cardinal` \| `catmull-rom` (`linear`) | Curve shape |
| Line / ECDF Plot | `showPoints` | boolean (false) | Draw point markers on the line |
| Sparkline | `baseline` | `mean` \| `zero` \| `median` \| `none` (`mean`) | Reference line per spark row |
@@ -359,12 +359,13 @@ derived). Values are clamped to the ranges shown.
| Histogram | `binCount` | 5–50 (10) | Number of bins |
| Density Plot | `bandwidth` | 0.05–2 (0=auto) | Kernel bandwidth |
| Pie Chart | `innerRadius` | 0–100 (0) | Donut hole size (>0 → donut) |
+| Pie / Rose | `sortSlices` | `none` \| `descending` \| `ascending` (`none`) | Order wedges and their legend by slice value |
| Rose Chart | `alignment` | `left` \| `center` (`left`) | Wedge alignment |
-| Rose Chart | `innerRadius` | 0–100 (0) | Inner radius |
| Rose Chart | `padAngle` | 0–0.1 (0) | Gap between slices |
| Lollipop | `dotSize` | 20–300 (80) | Circle size (px) |
| Waterfall | `cornerRadius` | 0–8 (0) | Round bar corners |
| Waterfall | `totals` | `auto` \| `none` \| `first` \| `last` \| `both` (`auto`) | Which bars anchor to zero as totals (only when no Type column) |
+| Waterfall | `showTextLabels` | boolean (false) | Render value labels on bars |
| Regression | `regressionMethod` | `linear` \| `log` \| `exp` \| `pow` \| `quad` \| `poly` (`linear`) | Fit method |
| Regression | `polyOrder` | 1–5 (3) | Polynomial order (when `poly`) |
| Radar | `filled` | boolean (true) | Fill the polygon |
diff --git a/packages/flint-mcp/package.json b/packages/flint-mcp/package.json
index 387ab22f..73defe50 100644
--- a/packages/flint-mcp/package.json
+++ b/packages/flint-mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "flint-chart-mcp",
- "version": "0.2.2",
+ "version": "0.3.0",
"description": "Model Context Protocol server for Flint — compile, validate, and render semantic chart specs to Vega-Lite, ECharts, or Chart.js artifacts (PNG/SVG) in-process.",
"keywords": [
"mcp",
@@ -68,7 +68,7 @@
"@resvg/resvg-js": "^2.6.2",
"chart.js": "^4.4.0",
"echarts": "^6.0.0",
- "flint-chart": "^0.2.2",
+ "flint-chart": "^0.3.0",
"vega": "^6.0.0",
"vega-interpreter": "^2.2.1",
"vega-lite": "^6.0.0",
diff --git a/packages/flint-mcp/src/server.ts b/packages/flint-mcp/src/server.ts
index e44080aa..5a3de6ec 100644
--- a/packages/flint-mcp/src/server.ts
+++ b/packages/flint-mcp/src/server.ts
@@ -333,6 +333,7 @@ export function createServer(options: CreateServerOptions = {}): McpServer {
uri: CHART_VIEW_RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
text: readChartViewHtml(),
+ _meta: { ui: { permissions: { clipboardWrite: {} } } },
},
],
}),
diff --git a/packages/flint-mcp/tests/server.test.ts b/packages/flint-mcp/tests/server.test.ts
index 3caa7e68..bf3300a5 100644
--- a/packages/flint-mcp/tests/server.test.ts
+++ b/packages/flint-mcp/tests/server.test.ts
@@ -204,6 +204,7 @@ describe('MCP server', () => {
const read = await client.readResource({ uri: 'ui://flint-chart/chart-view.html' });
const html = resourceText(read.contents[0]);
expect(html.toLowerCase()).toContain('');
+ expect((read.contents[0] as any)._meta?.ui?.permissions?.clipboardWrite).toEqual({});
});
it('exposes a prompt that embeds the agent skill', async () => { const { prompts } = await client.listPrompts();
diff --git a/packages/flint-mcp/ui/src/FlintApp.tsx b/packages/flint-mcp/ui/src/FlintApp.tsx
index 043a4e44..e00165cd 100644
--- a/packages/flint-mcp/ui/src/FlintApp.tsx
+++ b/packages/flint-mcp/ui/src/FlintApp.tsx
@@ -12,10 +12,12 @@
*/
import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
import { useApp } from '@modelcontextprotocol/ext-apps/react';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
import type { ChartAssemblyInput, ChartOption } from 'flint-chart';
import { renderFlintSvg, type FlintRenderResult } from './render';
+import { chartIconFor } from './chart-icons';
import {
buildPanelModel,
setProperty,
@@ -42,6 +44,80 @@ function compactSelectLabel(label: string): string {
return `${withoutHint.slice(0, 13).trimEnd()}...`;
}
+function DiscreteControl(props: {
+ label: string;
+ options: { value: unknown; label: string }[];
+ selectedIndex: number;
+ width: number;
+ onChange: (value: unknown) => void;
+}) {
+ const { label, options, selectedIndex, width, onChange } = props;
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+ const selected = options[selectedIndex];
+
+ useEffect(() => {
+ if (!open) return;
+ const onDoc = (event: MouseEvent) => {
+ if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
+ };
+ document.addEventListener('mousedown', onDoc);
+ return () => document.removeEventListener('mousedown', onDoc);
+ }, [open]);
+
+ return (
+