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. + +Switching a line chart to a Sparkline in the Flint MCP App + +## 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. + +Turning labels on for a heatmap in the Flint MCP App + +## Flint in Chinese + +The website and documentation are now available in Chinese, including guides for +getting started, authoring charts, and extending Flint. + +The Chinese Flint website and documentation + +## 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 @@ [![CI](https://github.com/microsoft/flint-chart/actions/workflows/ci.yml/badge.svg)](https://github.com/microsoft/flint-chart/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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. +

Flint compiling a compact chart spec into a Vega-Lite spec and rendered heatmap visualization. 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. | ### ![](chart-icon-range-area.svg) 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. | ### ![](chart-icon-lollipop.svg) 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. | ### ![](chart-icon-streamgraph.svg) 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. | ### ![](chart-icon-bullet.svg) 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 ` ### ![](chart-icon-column.svg) 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. | ### ![](chart-icon-lollipop.svg) 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 三阶段流水线 + +![Overview of the Flint architecture](figs/overview.png) + +| 阶段 | 作用 | 实现 | 关键输出 | +|-------|------------|----------------|-------------| +| **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 架构一览 + +![Overview of the Flint architecture](figs/overview.png) + +| 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 使其相关时出现。例如,对数比例尺控件仅出现在范围较宽的轴上。传入不适用的参数是安全的;组装器会忽略它们。 + +## 散点与点图 + +### ![](chart-icon-scatter.svg) Scatter Plot + +**编码通道:** `x`, `y`, `color`, `size`, `opacity`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `opacity` | number | 0.1 – 1 (step 0.05) | `1` | always | 标记不透明度。 | + +### ![](chart-icon-connected-scatter.svg) Connected Scatter Plot + +**编码通道:** `x`, `y`, `order`, `color`, `detail`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-bubble.svg) Bubble Chart + +**编码通道:** `x`, `y`, `size`, `color`, `opacity`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `opacity` | number | 0.1 – 1 (step 0.05) | `0.6` | always | 标记不透明度。 | + +### ![](chart-icon-strip-plot.svg) 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 | 标记不透明度。 | + +## 条形图 + +### ![](chart-icon-column.svg) Bar Chart + +**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `cornerRadius` | number | 0 – 15 (step 1) | `0` | always | 支持的标记的圆角半径。 | + +### ![](chart-icon-column-grouped.svg) Grouped Bar Chart + +**编码通道:** `x`, `y`, `group`, `color`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge | + +### ![](chart-icon-column-stacked.svg) Stacked Bar Chart + +**编码通道:** `x`, `y`, `color`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-combo.svg) Combo Chart + +**编码通道:** `x`, `y`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `cornerRadius` | number | 0 – 15 (step 1) | `0` | always | 支持的标记的圆角半径。 | + +### ![](chart-icon-histogram.svg) Histogram + +**编码通道:** `x`, `color`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `binCount` | number | 5 – 50 (step 1) | `Auto` | always | 最大分箱上限;Auto 由后端选择。 | + +### ![](chart-icon-waterfall.svg) Waterfall Chart + +**编码通道:** `x`, `y`, `color`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-gantt.svg) Gantt Chart + +**编码通道:** `y`, `x`, `x2`, `color`, `column`, `row` + +_无模板专属参数。_ + +## 折线与面积 + +### ![](chart-icon-line.svg) 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 | 折线或面积插值方法。 | + +### ![](chart-icon-slope.svg) Slope Chart + +**编码通道:** `x`, `y`, `color`, `detail`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-area.svg) 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 | 重叠系列的堆叠策略。 | + +### ![](chart-icon-range-area.svg) Range Area Chart + +**编码通道:** `x`, `y`, `y2`, `color`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-ecdf.svg) ECDF Plot + +**编码通道:** `x`, `color`, `detail`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `showPoints` | toggle | on / off | `false` | always | 在线上叠加点标记。 | + +## 部分与整体 + +### ![](chart-icon-pie.svg) 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 | 排序扇区 | + +### ![](chart-icon-doughnut.svg) 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 | 排序扇区 | + +## 极坐标 + +### ![](chart-icon-radar.svg) 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 | 面积或区域的填充不透明度。 | + +### ![](chart-icon-rose.svg) 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 使其相关时出现。例如,对数比例尺控件仅出现在范围较宽的轴上。传入不适用的参数是安全的;组装器会忽略它们。 + +## 散点与点图 + +### ![](chart-icon-scatter.svg) Scatter Plot + +**编码通道:** `x`, `y`, `color`, `size`, `opacity`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `opacity` | number | 0.1 – 1 (step 0.05) | `1` | always | 标记不透明度。 | + +### ![](chart-icon-linear-regression.svg) 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 | 回归拟合的多项式阶数。 | + +### ![](chart-icon-connected-scatter.svg) Connected Scatter Plot + +**编码通道:** `x`, `y`, `order`, `color`, `detail`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-dot-plot-horizontal.svg) Ranged Dot Plot + +**编码通道:** `x`, `y`, `color` + +_无模板专属参数。_ + +### ![](chart-icon-box-plot.svg) 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 | + +### ![](chart-icon-strip-plot.svg) Strip Plot + +**编码通道:** `x`, `y`, `color`, `size`, `column`, `row` + +_无模板专属参数。_ + +## 条形图 + +### ![](chart-icon-column.svg) Bar Chart + +**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `cornerRadius` | number | 0 – 15 (step 1) | `0` | always | 支持的标记的圆角半径。 | + +### ![](chart-icon-column-grouped.svg) Grouped Bar Chart + +**编码通道:** `x`, `y`, `group`, `color`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge | + +### ![](chart-icon-column-stacked.svg) Stacked Bar Chart + +**编码通道:** `x`, `y`, `color`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `layered` (Layered (overlap)) | — | conditional | 重叠系列的堆叠策略。 | + +### ![](chart-icon-lollipop.svg) Lollipop Chart + +**编码通道:** `x`, `y`, `color`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `dotSize` | number | 20 – 300 (step 10) | `80` | always | 圆点标记的大小。 | + +### ![](chart-icon-pyramid.svg) Pyramid Chart + +**编码通道:** `x`, `y`, `color` + +_无模板专属参数。_ + +### ![](chart-icon-heat-map.svg) Heatmap + +**编码通道:** `x`, `y`, `color`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-calendar.svg) Calendar Heatmap + +**编码通道:** `x`, `color` + +_无模板专属参数。_ + +## 折线与面积 + +### ![](chart-icon-line.svg) 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 | 在线上叠加点标记。 | + +### ![](chart-icon-bump.svg) Bump Chart + +**编码通道:** `x`, `y`, `color`, `detail`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-slope.svg) Slope Chart + +**编码通道:** `x`, `y`, `color`, `detail`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-area.svg) 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 | 重叠系列的堆叠策略。 | + +### ![](chart-icon-streamgraph.svg) Streamgraph + +**编码通道:** `x`, `y`, `color`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-range-area.svg) Range Area Chart + +**编码通道:** `x`, `y`, `y2`, `color`, `column`, `row` + +_无模板专属参数。_ + +## 部分与整体 + +### ![](chart-icon-pie.svg) 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 | 标签 | + +### ![](chart-icon-funnel.svg) 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 | 段之间的间距。 | + +### ![](chart-icon-treemap.svg) Treemap + +**编码通道:** `color`, `size`, `detail` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `breadcrumb` | choice | `true` (Show (default)), `false` (Hide) | — | always | 显示或隐藏 treemap 面包屑导航。 | + +### ![](chart-icon-sunburst.svg) 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 扇区的标签方向。 | + +### ![](chart-icon-tree.svg) Tree + +**编码通道:** `color`, `detail`, `size` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `orient` | choice | `LR` (Left → Right (default)), `TB` (Top → Bottom) | — | always | 图表方向。 | + +## 统计 + +### ![](chart-icon-histogram.svg) Histogram + +**编码通道:** `x`, `color`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `binCount` | number | 5 – 50 (step 1) | `Auto` | always | 最大分箱上限;Auto 由后端选择。 | + +### ![](chart-icon-density.svg) Density Plot + +**编码通道:** `x`, `color`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `bandwidth` | number | 0.05 – 2 (step 0.05) | `0` | always | 核密度带宽(0 = 自动)。 | + +### ![](chart-icon-ecdf.svg) ECDF Plot + +**编码通道:** `x`, `color`, `detail`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `showPoints` | toggle | on / off | `false` | always | 在线上叠加点标记。 | + +### ![](chart-icon-parallel.svg) Parallel Coordinates + +**编码通道:** `color`, `detail` + +_无模板专属参数。_ + +## 金融 + +### ![](chart-icon-candlestick.svg) Candlestick Chart + +**编码通道:** `x`, `open`, `high`, `low`, `close`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `showMA` | toggle | on / off | `false` | always | 显示移动平均线叠加层。 | +| `maWindow` | number | 3 – 30 (step 1) | `5` | always | 移动平均线窗口大小。 | + +## 其他 + +### ![](chart-icon-waterfall.svg) Waterfall Chart + +**编码通道:** `x`, `y`, `color`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-gantt.svg) Gantt Chart + +**编码通道:** `y`, `x`, `x2`, `color`, `detail`, `column`, `row` + +_无模板专属参数。_ + +### ![](chart-icon-bullet.svg) Bullet Chart + +**编码通道:** `y`, `x`, `goal`, `color`, `column`, `row` + +_无模板专属参数。_ + +## 极坐标 + +### ![](chart-icon-radar.svg) 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 | 面积或区域的填充不透明度。 | + +### ![](chart-icon-rose.svg) 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 | 排序扇区 | + +## 指标 + +### ![](chart-icon-gauge.svg) 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 | 进度 | + +## 流向 + +### ![](chart-icon-sankey.svg) 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 | 节点间距 | + +### ![](chart-icon-network.svg) 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 使其相关时出现。例如,对数比例尺控件仅出现在范围较宽的轴上。传入不适用的参数是安全的;组装器会忽略它们。 + +## 点图 + +### ![](chart-icon-scatter.svg) 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 轴锚定在零。 | + +### ![](chart-icon-linear-regression.svg) 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 轴锚定在零。 | + +### ![](chart-icon-connected-scatter.svg) 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 轴锚定在零。 | + +### ![](chart-icon-dot-plot-horizontal.svg) 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 轴锚定在零。 | + +### ![](chart-icon-strip-plot.svg) 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 轴锚定在零。 | + +## 条形图 + +### ![](chart-icon-column.svg) 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 轴解释为连续时间比例尺或离散波段。 | + +### ![](chart-icon-column-grouped.svg) 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 轴比例尺。 | + +### ![](chart-icon-column-stacked.svg) 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 轴比例尺。 | + +### ![](chart-icon-lollipop.svg) 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 轴解释为连续时间比例尺或离散波段。 | + +### ![](chart-icon-waterfall.svg) 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 轴比例尺。 | + +### ![](chart-icon-gantt.svg) 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 轴锚定在零。 | + +### ![](chart-icon-bullet.svg) Bullet Chart + +**编码通道:** `y`, `x`, `goal`, `color`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 | + +## 分布 + +### ![](chart-icon-histogram.svg) Histogram + +**编码通道:** `x`, `color`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `binCount` | number | 5 – 50 (step 1) | `Auto` | always | 最大分箱上限;Auto 由后端选择。 | +| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 | + +### ![](chart-icon-density.svg) Density Plot + +**编码通道:** `x`, `color`, `column`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `bandwidth` | number | 0.05 – 2 (step 0.05) | `0` | always | 核密度带宽(0 = 自动)。 | +| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 | + +### ![](chart-icon-ecdf.svg) 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 轴锚定在零。 | + +### ![](chart-icon-violin.svg) Violin Plot + +**编码通道:** `x`, `y`, `color`, `row` + +| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 描述 | +|---|---|---|---|---|---| +| `bandwidth` | number | 0.05 – 2 (step 0.05) | `0` | always | 核密度带宽(0 = 自动)。 | +| `independentYAxis` | toggle | on / off | `false` | conditional | 为分面使用独立的 y 轴比例尺。 | + +### ![](chart-icon-box-plot.svg) 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 轴锚定在零。 | + +### ![](chart-icon-pyramid.svg) Pyramid Chart + +**编码通道:** `x`, `y`, `color` + +_无模板专属参数。_ + +### ![](chart-icon-candlestick.svg) 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 轴锚定在零。 | + +## 折线与面积 + +### ![](chart-icon-line.svg) 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 轴锚定在零。 | + +### ![](chart-icon-bump.svg) 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 轴锚定在零。 | + +### ![](chart-icon-slope.svg) 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 轴锚定在零。 | + +### ![](chart-icon-area.svg) 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 轴解释为连续时间比例尺或离散波段。 | + +### ![](chart-icon-streamgraph.svg) 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 轴比例尺。 | + +### ![](chart-icon-range-area.svg) 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 轴比例尺。 | + +## 圆形图 + +### ![](chart-icon-pie.svg) 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 轴比例尺。 | + +### ![](chart-icon-rose.svg) 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 轴比例尺。 | + +### ![](chart-icon-radar.svg) 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 轴锚定在零。 | + +## 表格与地图 + +### ![](chart-icon-heat-map.svg) 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 轴解释为连续时间比例尺或离散波段。 | + +### ![](chart-icon-bar-table.svg) 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 轴比例尺。 | + +### ![](chart-icon-kpi-card.svg) 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 轴锚定在零。 | + +### ![](chart-icon-world-map.svg) 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 轴锚定在零。 | + +### ![](chart-icon-us-map.svg) 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 ( +

{ + if (event.key === 'Escape' && open) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } + }} + > + + {open && ( + + )} +
+ ); +} + // Best-effort sizing: measure each option by its label length + the intrinsic // width of its widget, then snap to a small set of tiers. Keeps the strip // grid-like (few distinct widths) while letting toggles stay compact and @@ -75,6 +151,20 @@ function ControlRow(props: { }) { const { label, spec, value, width, onChange } = props; + if (spec.type === 'discrete') { + const current = valueKey(value); + const index = spec.options.findIndex((option) => valueKey(option.value) === current); + return ( + + ); + } + let control: React.ReactNode = null; if (spec.type === 'continuous') { const step = spec.step ?? ((spec.max - spec.min) / 100 || 1); @@ -111,37 +201,6 @@ function ControlRow(props: { ); - } else if (spec.type === 'discrete') { - const current = valueKey(value); - const idx = spec.options.findIndex((o) => valueKey(o.value) === current); - const selectedIndex = idx < 0 ? 0 : idx; - const selected = spec.options[selectedIndex]; - // Hug the *current* value, not the widest option. A sized text label shows - // the selection while a transparent native onChange(spec.options[Number(e.target.value)]?.value)} - > - {spec.options.map((o, i) => ( - - ))} - - - ); } else { control = ( @@ -165,45 +224,202 @@ function ControlRow(props: { ); } -function PivotControl(props: { - pivot: NonNullable; - width: number; - onSelect: (id: string | undefined) => void; +/** + * Combined transform control: the chart-type switch (an icon dropdown listing + * sibling chart types) and the arrange cycle button (name ›) fused into ONE compact + * pill. When a chart has no sibling types the chart-type part is a static, + * non-clickable chip; when it has no arrangements the arrange part is omitted. + */ +function TransformControl(props: { + chartType?: PanelModel['pivot']; + arrange?: PanelModel['pivot']; + onChartType: (id: string | undefined) => void; + onArrange: (id: string | undefined) => void; }) { - const { pivot, width, onSelect } = props; - const { ids, labels, index, length, label } = pivot; - const go = (delta: number) => { - const nextIndex = (index + delta + length) % length; - // The identity state (index 0) is the absent override — clear it so the - // chart returns to the authored view rather than storing a redundant id. - onSelect(nextIndex === 0 ? undefined : ids[nextIndex]); + const { chartType, arrange, onChartType, onArrange } = props; + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [open]); + + const canSwitchType = !!chartType && chartType.length > 1; + const hasArrange = !!arrange && arrange.length > 1; + const curName = chartType ? chartType.labels[chartType.index] : undefined; + const curIcon = curName ? chartIconFor(curName) : undefined; + const arrangeLabel = hasArrange ? arrange!.labels[arrange!.index] : ''; + + const goArrange = (delta: number) => { + if (!arrange) return; + const n = (arrange.index + delta + arrange.length) % arrange.length; + onArrange(n === 0 ? undefined : arrange.ids[n]); }; + return ( -
- {label} -
- - - {index + 1} / {length} - - -
+
+ {chartType && + (canSwitchType ? ( + + ) : ( + + {curIcon && } + + ))} + + {chartType && hasArrange && } + + {hasArrange && ( +
+ +
+ )} + + {open && canSwitchType && chartType && ( +
    + {chartType.labels.map((lbl, i) => { + const ic = chartIconFor(lbl); + const selected = i === chartType.index; + return ( +
  • { + onChartType(i === 0 ? undefined : chartType.ids[i]); + setOpen(false); + }} + > + {ic && } + {lbl} +
  • + ); + })} +
+ )}
); } +function ActionButton(props: { + label: string; + className?: string; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + const { label, className = '', disabled = false, onClick, children } = props; + const [showTip, setShowTip] = useState(false); + const [tipPosition, setTipPosition] = useState({ left: 0, top: 0 }); + const anchorRef = useRef(null); + const tipRef = useRef(null); + + useLayoutEffect(() => { + if (!showTip || !anchorRef.current || !tipRef.current) return; + const anchor = anchorRef.current.getBoundingClientRect(); + const tip = tipRef.current.getBoundingClientRect(); + const gutter = 6; + const left = Math.min( + window.innerWidth - tip.width - gutter, + Math.max(gutter, anchor.left + (anchor.width - tip.width) / 2), + ); + const above = anchor.top - tip.height - gutter; + const top = above >= gutter ? above : anchor.bottom + gutter; + setTipPosition({ left, top }); + }, [label, showTip]); + + useEffect(() => { + if (!showTip) return; + const hide = () => setShowTip(false); + window.addEventListener('scroll', hide, true); + window.addEventListener('resize', hide); + return () => { + window.removeEventListener('scroll', hide, true); + window.removeEventListener('resize', hide); + }; + }, [showTip]); + + return ( + setShowTip(true)} + onMouseLeave={() => setShowTip(false)} + onFocus={() => setShowTip(true)} + onBlur={() => setShowTip(false)} + > + + {showTip && createPortal( + + {label} + , + document.body, + )} + + ); +} + function OptionsBar(props: { input: ChartAssemblyInput; model: PanelModel; onInput: (next: ChartAssemblyInput) => void; - onSend: () => void; - sent: boolean; + onReset: () => void; + canReset: boolean; + onCopyPng: () => void; + copyStatus: 'idle' | 'copying' | 'copied' | 'downloaded' | 'error'; + copyError: string | null; }) { - const { input, model, onInput, onSend, sent } = props; + const { input, model, onInput, onReset, canReset, onCopyPng, copyStatus, copyError } = props; + const copyFeedback = { + idle: 'Copy PNG', + copying: 'Copying PNG…', + copied: 'PNG copied', + downloaded: 'PNG downloaded', + error: copyError ? `Copy failed: ${copyError}` : 'Could not copy PNG', + }[copyStatus]; // Lean bar: surface only Flint's dynamic low-level options — visual chart // properties plus encoding actions (sort, …) — inline below the chart, @@ -227,15 +443,20 @@ function OptionsBar(props: { return (
- {model.pivot && model.pivot.length > 1 && ( - onInput(setProperty(input, model.pivot!.key, id))} + {((model.chartType && model.chartType.length > 1) || + (model.arrange && model.arrange.length > 1)) && ( + onInput(setProperty(input, model.chartType!.key, id))} + onArrange={(id) => onInput(setProperty(input, model.arrange!.key, id))} /> )} {controls.length === 0 ? ( - !(model.pivot && model.pivot.length > 1) && ( + !( + (model.chartType && model.chartType.length > 1) || + (model.arrange && model.arrange.length > 1) + ) && ( No adjustable options for this chart. ) ) : ( @@ -250,24 +471,35 @@ function OptionsBar(props: { /> )) )} - + + + {copyStatus === 'copied' || copyStatus === 'downloaded' ? ( + + ) : ( + + )} + +
); @@ -282,23 +514,24 @@ export function FlintAppInner(props: { const [current, setCurrent] = useState(input); const [render, setRender] = useState(null); const [error, setError] = useState(null); - const [sent, setSent] = useState(false); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copying' | 'copied' | 'downloaded' | 'error'>('idle'); + const [copyError, setCopyError] = useState(null); const renderSeq = useRef(0); // Re-seed when a new tool input arrives from the host. useEffect(() => setCurrent(input), [input]); - // Clear the "Copied" confirmation a couple of seconds after a successful send. + // Clear export feedback after a couple of seconds. useEffect(() => { - if (!sent) return; - const handle = window.setTimeout(() => setSent(false), 2000); + if (copyStatus === 'idle' || copyStatus === 'copying') return; + const handle = window.setTimeout(() => setCopyStatus('idle'), 2500); return () => window.clearTimeout(handle); - }, [sent]); + }, [copyStatus]); // Live render (debounced) whenever the working spec changes. useEffect(() => { const seq = ++renderSeq.current; - setSent(false); + setCopyStatus('idle'); const handle = setTimeout(() => { renderFlintSvg(current) .then((result) => { @@ -317,23 +550,60 @@ export function FlintAppInner(props: { }, [current]); const model = useMemo(() => buildPanelModel(current), [current]); + const canReset = useMemo( + () => JSON.stringify(current.chart_spec) !== JSON.stringify(input.chart_spec), + [current.chart_spec, input.chart_spec], + ); - const handleSend = useCallback(async () => { - const payload = { - chart_spec: current.chart_spec, - ...(current.semantic_types ? { semantic_types: current.semantic_types } : {}), - }; - const text = - 'Updated Flint chart spec from the chart view:\n\n```json\n' + - JSON.stringify(payload, null, 2) + - '\n```'; + const handleCopyPng = useCallback(async () => { + if (!render?.png) return; + setCopyStatus('copying'); + setCopyError(null); + const png = render.png; + let clipboardError: unknown; + try { + if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]); + setCopyStatus('copied'); + return; + } + } catch (err) { + clipboardError = err; + // Fall through to a host-mediated download when clipboard images are denied. + } + if (!app.getHostCapabilities()?.downloadFile) { + const message = clipboardError instanceof Error + ? clipboardError.message + : 'Image clipboard access is unavailable in this host'; + setCopyError(message); + setCopyStatus('error'); + return; + } try { - await app.sendMessage({ role: 'user', content: [{ type: 'text', text }] }); - setSent(true); - } catch { - /* host may reject; ignore */ + const bytes = new Uint8Array(await png.arrayBuffer()); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + const result = await app.downloadFile({ + contents: [{ + type: 'resource', + resource: { + uri: 'file:///flint-chart.png', + mimeType: 'image/png', + blob: btoa(binary), + }, + }], + }); + if (result.isError) { + setCopyError('The host declined the PNG download'); + setCopyStatus('error'); + } else { + setCopyStatus('downloaded'); + } + } catch (err) { + setCopyError(err instanceof Error ? err.message : 'PNG export failed'); + setCopyStatus('error'); } - }, [app, current]); + }, [app, render]); const warnings = render?.warnings ?? []; @@ -374,8 +644,11 @@ export function FlintAppInner(props: { input={current} model={model} onInput={setCurrent} - onSend={handleSend} - sent={sent} + onReset={() => setCurrent(input)} + canReset={canReset} + onCopyPng={handleCopyPng} + copyStatus={copyStatus} + copyError={copyError} /> ); diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-area.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-area.svg new file mode 100644 index 00000000..43d1829a --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-area.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bar-table.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bar-table.svg new file mode 100644 index 00000000..673fbe54 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bar-table.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-box-plot.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-box-plot.svg new file mode 100644 index 00000000..14f89746 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-box-plot.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bubble.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bubble.svg new file mode 100644 index 00000000..0b16762f --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bubble.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bullet.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bullet.svg new file mode 100644 index 00000000..e86a268d --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bullet.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bump.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bump.svg new file mode 100644 index 00000000..d7343464 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-bump.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-calendar.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-calendar.svg new file mode 100644 index 00000000..6b8d6634 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-calendar.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-candlestick.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-candlestick.svg new file mode 100644 index 00000000..4b558e7d --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-candlestick.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-column-grouped.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-column-grouped.svg new file mode 100644 index 00000000..60c0f156 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-column-grouped.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-column-stacked.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-column-stacked.svg new file mode 100644 index 00000000..a3568c45 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-column-stacked.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-column.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-column.svg new file mode 100644 index 00000000..459c1217 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-column.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-combo.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-combo.svg new file mode 100644 index 00000000..24a8705c --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-combo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-connected-scatter.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-connected-scatter.svg new file mode 100644 index 00000000..e38e99ec --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-connected-scatter.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-area.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-area.svg new file mode 100644 index 00000000..5bf7c088 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-area.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-bar.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-bar.svg new file mode 100644 index 00000000..53adc117 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-bar.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-line.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-line.svg new file mode 100644 index 00000000..2a16a88b --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-line.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-point.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-point.svg new file mode 100644 index 00000000..1a8cf826 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-point.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-rect.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-rect.svg new file mode 100644 index 00000000..59a25c7c --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-custom-rect.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-density.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-density.svg new file mode 100644 index 00000000..528e15f1 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-density.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-dot-plot-horizontal.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-dot-plot-horizontal.svg new file mode 100644 index 00000000..d340a5d4 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-dot-plot-horizontal.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-dotted-line.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-dotted-line.svg new file mode 100644 index 00000000..fa9b6d08 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-dotted-line.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-doughnut.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-doughnut.svg new file mode 100644 index 00000000..7965de47 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-doughnut.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-ecdf.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-ecdf.svg new file mode 100644 index 00000000..6c8ac163 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-ecdf.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-funnel.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-funnel.svg new file mode 100644 index 00000000..2c30737a --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-funnel.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-gantt.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-gantt.svg new file mode 100644 index 00000000..454c5949 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-gantt.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-gauge.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-gauge.svg new file mode 100644 index 00000000..8a83507d --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-gauge.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-heat-map.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-heat-map.svg new file mode 100644 index 00000000..c08bf3b5 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-heat-map.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-histogram.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-histogram.svg new file mode 100644 index 00000000..9b4dc990 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-histogram.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-kpi-card.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-kpi-card.svg new file mode 100644 index 00000000..b1d42984 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-kpi-card.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-line.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-line.svg new file mode 100644 index 00000000..84ed1d6f --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-line.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-linear-regression.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-linear-regression.svg new file mode 100644 index 00000000..9c4b8782 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-linear-regression.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-lollipop.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-lollipop.svg new file mode 100644 index 00000000..5c48509f --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-lollipop.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-network.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-network.svg new file mode 100644 index 00000000..799bb231 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-network.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-parallel.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-parallel.svg new file mode 100644 index 00000000..aaad272c --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-parallel.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-pie.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-pie.svg new file mode 100644 index 00000000..7fe15948 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-pie.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-pyramid.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-pyramid.svg new file mode 100644 index 00000000..2d758a92 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-pyramid.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-radar.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-radar.svg new file mode 100644 index 00000000..61201484 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-radar.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-range-area.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-range-area.svg new file mode 100644 index 00000000..a7d39e3d --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-range-area.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-rose.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-rose.svg new file mode 100644 index 00000000..c96560ff --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-rose.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-sankey.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-sankey.svg new file mode 100644 index 00000000..954efeda --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-sankey.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-scatter.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-scatter.svg new file mode 100644 index 00000000..11ca74c9 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-scatter.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-slope.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-slope.svg new file mode 100644 index 00000000..88dc6ca1 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-slope.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-sparkline.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-sparkline.svg new file mode 100644 index 00000000..8726b01a --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-sparkline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-streamgraph.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-streamgraph.svg new file mode 100644 index 00000000..317b0063 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-streamgraph.svg @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-strip-plot.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-strip-plot.svg new file mode 100644 index 00000000..dc407b6a --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-strip-plot.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-sunburst.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-sunburst.svg new file mode 100644 index 00000000..9d40602d --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-sunburst.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-tree.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-tree.svg new file mode 100644 index 00000000..77b2157d --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-tree.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-treemap.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-treemap.svg new file mode 100644 index 00000000..7c57477e --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-treemap.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-us-map.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-us-map.svg new file mode 100644 index 00000000..1ef53997 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-us-map.svg @@ -0,0 +1,68 @@ + + +Created by potrace 1.15, written by Peter Selinger 2001-2017 + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-violin.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-violin.svg new file mode 100644 index 00000000..32eae7b6 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-violin.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-waterfall.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-waterfall.svg new file mode 100644 index 00000000..72fdf9fa --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-waterfall.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-world-map.svg b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-world-map.svg new file mode 100644 index 00000000..594dc061 --- /dev/null +++ b/packages/flint-mcp/ui/src/assets/chart-icons/chart-icon-world-map.svg @@ -0,0 +1,47 @@ + + + + + + + diff --git a/packages/flint-mcp/ui/src/chart-icons.ts b/packages/flint-mcp/ui/src/chart-icons.ts new file mode 100644 index 00000000..be5daec2 --- /dev/null +++ b/packages/flint-mcp/ui/src/chart-icons.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Chart-type display name → icon asset URL, for the chart-type switcher. + * + * The SVGs live under `./assets/chart-icons/` (copied from the site's icon set). + * Names match the `chart` field on every backend's `ChartTemplateDef`; unmapped + * types simply render without an icon. + */ + +const ICON_FILE_BY_CHART: Record = { + 'Scatter Plot': 'chart-icon-scatter.svg', + 'Regression': 'chart-icon-linear-regression.svg', + 'Connected Scatter Plot': 'chart-icon-connected-scatter.svg', + 'Ranged Dot Plot': 'chart-icon-dot-plot-horizontal.svg', + 'Strip Plot': 'chart-icon-strip-plot.svg', + 'Boxplot': 'chart-icon-box-plot.svg', + 'Bubble Chart': 'chart-icon-bubble.svg', + 'Bar Chart': 'chart-icon-column.svg', + 'Grouped Bar Chart': 'chart-icon-column-grouped.svg', + 'Stacked Bar Chart': 'chart-icon-column-stacked.svg', + 'Pyramid Chart': 'chart-icon-pyramid.svg', + 'Histogram': 'chart-icon-histogram.svg', + 'Heatmap': 'chart-icon-heat-map.svg', + 'Calendar Heatmap': 'chart-icon-calendar.svg', + 'Lollipop Chart': 'chart-icon-lollipop.svg', + 'Waterfall Chart': 'chart-icon-waterfall.svg', + 'Gantt Chart': 'chart-icon-gantt.svg', + 'Bullet Chart': 'chart-icon-bullet.svg', + 'Combo Chart': 'chart-icon-combo.svg', + 'Bar Table': 'chart-icon-bar-table.svg', + 'Density Plot': 'chart-icon-density.svg', + 'ECDF Plot': 'chart-icon-ecdf.svg', + 'Violin Plot': 'chart-icon-violin.svg', + 'Candlestick Chart': 'chart-icon-candlestick.svg', + 'Parallel Coordinates': 'chart-icon-parallel.svg', + 'Line Chart': 'chart-icon-line.svg', + 'Sparkline': 'chart-icon-sparkline.svg', + 'Bump Chart': 'chart-icon-bump.svg', + 'Slope Chart': 'chart-icon-slope.svg', + 'Area Chart': 'chart-icon-area.svg', + 'Streamgraph': 'chart-icon-streamgraph.svg', + 'Range Area Chart': 'chart-icon-range-area.svg', + 'Pie Chart': 'chart-icon-pie.svg', + 'Doughnut Chart': 'chart-icon-doughnut.svg', + 'Scatter Pie Chart': 'chart-icon-pie.svg', + 'Rose Chart': 'chart-icon-rose.svg', + 'Radar Chart': 'chart-icon-radar.svg', + 'Gauge Chart': 'chart-icon-gauge.svg', + 'Funnel Chart': 'chart-icon-funnel.svg', + 'Treemap': 'chart-icon-treemap.svg', + 'Sunburst Chart': 'chart-icon-sunburst.svg', + 'Tree': 'chart-icon-tree.svg', + 'Sankey Diagram': 'chart-icon-sankey.svg', + 'Network Graph': 'chart-icon-network.svg', + 'KPI Card': 'chart-icon-kpi-card.svg', + 'Map': 'chart-icon-world-map.svg', + 'Choropleth': 'chart-icon-us-map.svg', +}; + +// Resolve every icon file to its bundled URL at build time. +const ICON_URLS = import.meta.glob('./assets/chart-icons/*.svg', { + eager: true, + query: '?url', + import: 'default', +}) as Record; + +/** Look up a chart type's icon URL by its display name (e.g. 'Bar Chart'). */ +export function chartIconFor(name: string): string | undefined { + const file = ICON_FILE_BY_CHART[name]; + if (!file) return undefined; + return ICON_URLS[`./assets/chart-icons/${file}`]; +} diff --git a/packages/flint-mcp/ui/src/options.ts b/packages/flint-mcp/ui/src/options.ts index 80e50cc0..0f8b83e9 100644 --- a/packages/flint-mcp/ui/src/options.ts +++ b/packages/flint-mcp/ui/src/options.ts @@ -16,6 +16,7 @@ import { getChartOptions, getChartPivot, + getChartTransform, resolveEncodingType, vlAllTemplateDefs, vlGetTemplateDef, @@ -50,6 +51,10 @@ export interface PanelModel { actions: ResolvedAction[]; /** Cyclic pivot surface (alternative views), or undefined when single-view. */ pivot?: PivotSurface; + /** Control B — chart-type transitions (θ), or undefined when no siblings. */ + chartType?: PivotSurface; + /** Control A — local rearrangement group (τ/σ/γ), or undefined when trivial. */ + arrange?: PivotSurface; } /** Sorted list of every available Vega-Lite chart type. */ @@ -172,7 +177,19 @@ export function buildPanelModel(input: ChartAssemblyInput): PanelModel { pivot = undefined; } - return { chartTypes: allChartTypes(), channels, bindings, properties, actions, pivot }; + // Factored two-control transform surfaces (chart type = θ, arrange = τ/σ/γ). + let chartType: PivotSurface | undefined; + let arrange: PivotSurface | undefined; + try { + const transform = getChartTransform(input); + chartType = transform?.chartType; + arrange = transform?.arrange; + } catch { + chartType = undefined; + arrange = undefined; + } + + return { chartTypes: allChartTypes(), channels, bindings, properties, actions, pivot, chartType, arrange }; } // --------------------------------------------------------------------------- diff --git a/packages/flint-mcp/ui/src/render.ts b/packages/flint-mcp/ui/src/render.ts index 3e485552..ebce92d8 100644 --- a/packages/flint-mcp/ui/src/render.ts +++ b/packages/flint-mcp/ui/src/render.ts @@ -16,6 +16,10 @@ import { expressionInterpreter } from 'vega-interpreter'; export interface FlintRenderResult { /** Rendered SVG markup. */ svg: string; + /** High-resolution PNG rendered by the same Vega view, ready for clipboard/export. */ + png: Blob; + /** Encoded PNG dimensions in physical pixels. */ + pngSize: { width: number; height: number }; /** The assembled Vega-Lite spec (Flint annotations left in place). */ vlSpec: Record; /** Assembler warnings, if any. */ @@ -23,10 +27,18 @@ export interface FlintRenderResult { } const DEFAULT_BACKGROUND = '#ffffff'; -const APP_PREVIEW_BASE_SIZE = { width: 360, height: 360 } as const; -const APP_PREVIEW_CANVAS_SIZE = { width: 720, height: 720 } as const; +const APP_PREVIEW_BASE_SIZE = { width: 360, height: 270 } as const; +const APP_PREVIEW_CANVAS_SIZE = { width: 720, height: 540 } as const; const APP_PREVIEW_MIN_STEP_PLOT_SIZE = { width: 220, height: 160 } as const; const APP_PREVIEW_MAX_AUTO_STEP = 96; +const COPY_PNG_TARGET_LONG_EDGE = 1920; +const COPY_PNG_MAX_SCALE = 4; + +function copyPngScale(width: number, height: number): number { + const longEdge = Math.max(width, height); + if (!Number.isFinite(longEdge) || longEdge <= 0) return 2; + return Math.min(COPY_PNG_MAX_SCALE, Math.max(1, COPY_PNG_TARGET_LONG_EDGE / longEdge)); +} function usesAutoPreviewSize(input: ChartAssemblyInput): boolean { return !input.chart_spec.baseSize && !input.chart_spec.canvasSize; @@ -116,8 +128,21 @@ export async function renderFlintSvg( const view = new View(runtime, { renderer: 'none', expr: expressionInterpreter }); view.logLevel(VegaError); await view.runAsync(); - const svg = await view.toSVG(); + const pngScale = copyPngScale(view.width(), view.height()); + const [svg, canvas] = await Promise.all([view.toSVG(), view.toCanvas(pngScale)]); + const png = await new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => blob ? resolve(blob) : reject(new Error('Could not encode chart PNG')), + 'image/png', + ); + }); view.finalize(); - return { svg, vlSpec, warnings }; + return { + svg, + png, + pngSize: { width: canvas.width, height: canvas.height }, + vlSpec, + warnings, + }; } diff --git a/packages/flint-mcp/ui/src/styles.css b/packages/flint-mcp/ui/src/styles.css index c7568df0..8b0a4ea7 100644 --- a/packages/flint-mcp/ui/src/styles.css +++ b/packages/flint-mcp/ui/src/styles.css @@ -12,7 +12,7 @@ --accent: #3a3a3a; --warn: #8a6d00; --error: #b00020; - --font: Arial, Roboto, 'Helvetica Neue', sans-serif; + --font: Arial, Roboto, "Helvetica Neue", sans-serif; --option-cell-width: 176px; --option-readout-width: 44px; } @@ -57,13 +57,23 @@ body { background: var(--paper); padding: 6px; display: flex; + width: 100%; + max-width: 600px; + height: 75vw; + max-height: 360px; + margin-inline: auto; justify-content: center; + align-items: center; overflow: auto; } .chart svg { - max-width: 100%; + display: block; + width: auto; height: auto; + max-width: 100%; + max-height: 100%; + flex: none; } .placeholder { @@ -166,6 +176,11 @@ body { outline-offset: 0; } +.opt-discrete { + position: relative; + padding: 0; +} + .opt-label { color: var(--muted); font-size: 11px; @@ -225,6 +240,168 @@ body { font-variant-numeric: tabular-nums; } +/* Combined transform control: chart-type icon dropdown + arrange switcher, + fused into one compact pill. */ +.transform-control { + position: relative; + display: inline-flex; + align-items: stretch; + gap: 0; + border-radius: 7px; + background: rgba(0, 0, 0, 0.05); +} + +.tc-type { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border: none; + border-radius: 7px 0 0 7px; + outline: none; + background: transparent; + color: var(--ink); + font-size: 11px; + line-height: 1.2; + cursor: pointer; + transition: background 120ms ease; +} + +.tc-type:hover, +.tc-type[aria-expanded="true"] { + background: rgba(0, 0, 0, 0.07); +} + +.tc-type-static { + cursor: default; +} + +.tc-type-static:hover { + background: transparent; +} + +.tc-icon { + width: 14px; + height: 14px; + display: block; + flex: 0 0 auto; +} + +.tc-caret { + color: var(--muted); + flex: 0 0 auto; +} + +.tc-divider { + width: 1px; + align-self: stretch; + margin: 5px 0; + background: var(--hairline); +} + +.tc-arrange { + display: inline-flex; + align-items: stretch; + min-width: 0; +} + +.tc-arrange-next { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-width: 0; + min-height: 26px; + padding: 0 8px 0 5px; + border: none; + border-radius: 0 7px 7px 0; + background: transparent; + color: inherit; + cursor: pointer; + transition: background 120ms ease; +} + +.tc-arrange:first-child .tc-arrange-next { + border-radius: 7px; +} + +.tc-arrange-next:hover { + background: rgba(0, 0, 0, 0.07); +} + +.tc-arrange-next:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.arrange-state { + min-width: 2.5em; + max-width: 11em; + text-align: center; + font-size: 11px; + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tc-arrange-arrow { + flex: 0 0 auto; + font-size: 12px; + line-height: 1; +} + +.tc-menu { + position: absolute; + bottom: calc(100% + 5px); + left: 0; + z-index: 60; + margin: 0; + padding: 4px; + list-style: none; + min-width: 180px; + max-height: 320px; + overflow-y: auto; + border: 1px solid var(--hairline); + border-radius: 10px; + background: var(--paper); + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.14); +} + +.tc-opt { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 7px; + cursor: pointer; + font-size: 11px; + color: var(--ink); +} + +.tc-opt:hover { + background: rgba(0, 0, 0, 0.04); +} + +.tc-opt-selected { + background: rgba(0, 0, 0, 0.06); +} + +.tc-opt-icon { + width: 15px; + height: 15px; + display: block; + flex: 0 0 auto; +} + +.tc-opt span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .control-inline { display: grid; grid-template-columns: 44px auto; @@ -291,7 +468,7 @@ body { } select, -input[type='range'] { +input[type="range"] { font-family: inherit; font-size: 11px; color: var(--ink); @@ -302,14 +479,27 @@ select { min-width: 0; } -/* Compact dropdown: visible width hugs the current value; a transparent native - select overlays the whole control for native picking + keyboard. */ -.select-wrap { - position: relative; +/* Property dropdown: the whole pill is one button; the menu borrows the chart + type selector's visual language and opens directly above the widget. */ +.select-trigger { + appearance: none; + border: none; + background: transparent; + color: inherit; + font-family: inherit; display: inline-flex; align-items: center; - gap: 3px; + gap: 5px; min-width: 0; + min-height: 19px; + padding: 0 5px 0 7px; + border-radius: 5px; + cursor: pointer; +} + +.select-trigger:hover, +.select-trigger[aria-expanded="true"] { + background: rgba(0, 0, 0, 0.025); } .select-value { @@ -328,23 +518,55 @@ select { pointer-events: none; } -.select-native { +.select-menu { position: absolute; - inset: 0; - width: 100%; - height: 100%; + z-index: 60; margin: 0; - padding: 0; + padding: 4px; + min-width: 150px; + max-height: 240px; + overflow-y: auto; + list-style: none; + border: 1px solid var(--hairline); + border-radius: 10px; + background: var(--paper); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); +} + +.select-menu-top { + bottom: calc(100% + 5px); + left: -1px; + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.14); +} + +.select-option { + appearance: none; + width: 100%; + padding: 6px 8px; border: none; - opacity: 0; + border-radius: 7px; + background: transparent; + color: var(--ink); + font-family: inherit; + font-size: 11px; + text-align: left; cursor: pointer; - appearance: none; +} + +.select-option:hover, +.select-option:focus-visible { + background: rgba(0, 0, 0, 0.04); + outline: none; +} + +.select-option-selected { + background: rgba(0, 0, 0, 0.06); } /* Slim slider: a 3px track with a filled-progress left half and a small thumb, so it reads as compact as the surrounding chips rather than a chunky native control. Fill percent comes from --pct (set inline from the current value). */ -input[type='range'] { +input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; @@ -355,7 +577,7 @@ input[type='range'] { cursor: pointer; } -input[type='range']::-webkit-slider-runnable-track { +input[type="range"]::-webkit-slider-runnable-track { height: 3px; border-radius: 999px; background: linear-gradient( @@ -365,7 +587,7 @@ input[type='range']::-webkit-slider-runnable-track { ); } -input[type='range']::-webkit-slider-thumb { +input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 9px; @@ -376,19 +598,19 @@ input[type='range']::-webkit-slider-thumb { background: var(--accent); } -input[type='range']::-moz-range-track { +input[type="range"]::-moz-range-track { height: 3px; border-radius: 999px; background: rgba(0, 0, 0, 0.16); } -input[type='range']::-moz-range-progress { +input[type="range"]::-moz-range-progress { height: 3px; border-radius: 999px; background: var(--accent); } -input[type='range']::-moz-range-thumb { +input[type="range"]::-moz-range-thumb { width: 9px; height: 9px; border: none; @@ -396,11 +618,35 @@ input[type='range']::-moz-range-thumb { background: var(--accent); } -input[type='checkbox'] { +input[type="checkbox"] { accent-color: var(--accent); } -/* trailing icon action (copy spec) -- shaded to match the control pills */ +/* Trailing actions flow immediately after the controls and wrap as one unit. */ +.bar-actions { + display: inline-flex; + align-items: center; + gap: 3px; + flex: none; +} + +.action-tip-anchor { + display: inline-flex; +} + +.action-tooltip { + position: fixed; + z-index: 10000; + padding: 3px 6px; + color: var(--paper); + background: var(--ink); + border-radius: 4px; + font-size: 11px; + line-height: 1; + white-space: nowrap; + pointer-events: none; +} + .bar-link { position: relative; font-family: inherit; @@ -411,14 +657,13 @@ input[type='checkbox'] { height: 21px; padding: 0; color: var(--muted); - background: rgba(0, 0, 0, 0.045); + background: transparent; border: 1px solid transparent; border-radius: 6px; cursor: pointer; - /* Anchor the action to the trailing edge so it never strands mid-wrap - between control chips. */ - margin-left: auto; - transition: background 120ms ease, color 120ms ease; + transition: + background 120ms ease, + color 120ms ease; } .bar-link svg { @@ -432,31 +677,12 @@ input[type='checkbox'] { } .bar-link:disabled { - background: rgba(0, 0, 0, 0.045); + background: transparent; color: var(--accent); cursor: default; } -/* hover hint -- anchored to the button's right edge so it never overflows */ -.bar-link[data-tip]::after { - content: attr(data-tip); - position: absolute; - bottom: calc(100% + 6px); - right: 0; - padding: 3px 6px; - font-size: 11px; - line-height: 1; - white-space: nowrap; - color: var(--paper); - background: var(--ink); - border-radius: 4px; - opacity: 0; - pointer-events: none; - transition: opacity 100ms ease; -} - -.bar-link[data-tip]:hover::after, -.bar-link[data-tip]:focus-visible::after { - opacity: 1; +.bar-reset:disabled { + color: var(--muted); + opacity: 0.4; } - diff --git a/packages/flint-py/flint/vegalite/templates/area.py b/packages/flint-py/flint/vegalite/templates/area.py index 5acf49da..8b6809c5 100644 --- a/packages/flint-py/flint/vegalite/templates/area.py +++ b/packages/flint-py/flint/vegalite/templates/area.py @@ -59,7 +59,11 @@ def _area_instantiate(spec, ctx): ], }, {"key": "opacity", "label": "Opacity", "type": "continuous", - "min": 0.1, "max": 1, "step": 0.05, "defaultValue": 0.7}, + "min": 0.1, "max": 1, "step": 0.05, "defaultValue": 0.7, + "check": lambda ctx: {"applicable": bool( + ((ctx.get("encodings") or {}).get("color") or {}).get("field") + and (ctx.get("chartProperties") or {}).get("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. diff --git a/packages/flint-py/tests/unit/test_chart_option_applicability.py b/packages/flint-py/tests/unit/test_chart_option_applicability.py index 9822d5c4..86eef505 100644 --- a/packages/flint-py/tests/unit/test_chart_option_applicability.py +++ b/packages/flint-py/tests/unit/test_chart_option_applicability.py @@ -51,6 +51,29 @@ def test_stack_mode_not_applicable_without_color_channel(): assert "stackMode" not in _applicable_keys(spec) +def _area_spec(*, layered: bool, with_color: bool = True) -> dict[str, Any]: + return assemble_vegalite({ + "data": {"values": STACK_ROWS}, + "semantic_types": {"region": "Category", "cat": "Category", "val": "Quantity"}, + "chart_spec": { + "chartType": "Area Chart", + "encodings": { + "x": {"field": "region"}, + "y": {"field": "val"}, + **({"color": {"field": "cat"}} if with_color else {}), + }, + "chartProperties": {"stackMode": "layered"} if layered else {}, + "baseSize": CANVAS, + }, + }) + + +def test_area_opacity_only_applicable_for_layered_multi_series(): + assert "opacity" not in _applicable_keys(_area_spec(layered=False)) + assert "opacity" not in _applicable_keys(_area_spec(layered=True, with_color=False)) + assert "opacity" in _applicable_keys(_area_spec(layered=True)) + + # ── independentYAxis ──────────────────────────────────────────────────────── FACET_ROWS = [ diff --git a/scripts/gen-chart-reference.ts b/scripts/gen-chart-reference.ts index 863eea26..5b8f8e48 100644 --- a/scripts/gen-chart-reference.ts +++ b/scripts/gen-chart-reference.ts @@ -77,6 +77,8 @@ const PARAM_DESCRIPTIONS: Record = { pointSize: 'Point or marker size.', dotSize: 'Size of the dot mark.', cornerRadius: 'Corner radius for supported marks.', + taskHeight: 'Task bar height as a percentage of each row.', + intervalLabels: 'Text shown on task intervals.', interpolate: 'Line or area interpolation method.', showPoints: 'Overlay point markers on the line.', showTextLabels: 'Render value labels on the marks.', diff --git a/site/package.json b/site/package.json index 88110524..72d39abd 100644 --- a/site/package.json +++ b/site/package.json @@ -17,9 +17,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/site/src/assets/chart-icons/chart-icon-sparkline.svg b/site/src/assets/chart-icons/chart-icon-sparkline.svg index d4eda542..8726b01a 100644 --- a/site/src/assets/chart-icons/chart-icon-sparkline.svg +++ b/site/src/assets/chart-icons/chart-icon-sparkline.svg @@ -1,31 +1,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + diff --git a/site/src/components/ChartCodeModal.tsx b/site/src/components/ChartCodeModal.tsx index bd0dccdb..2aac5c11 100644 --- a/site/src/components/ChartCodeModal.tsx +++ b/site/src/components/ChartCodeModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import type { TestCase } from 'flint-chart/test-data'; import { JsonCodeMirror } from './JsonCodeMirror'; import { ScaleToFit } from './ScaleToFit'; @@ -7,6 +8,7 @@ import { GalleryOptionsBar } from './GalleryOptionsBar'; import { testCaseToAssemblyInput } from '../shared/test-case-utils'; import { buildPanelModel } from '../shared/chart-options'; import { buildGalleryEditorHref } from '../shared/editor-payload'; +import { useLocale } from '../i18n/LocaleContext'; import { humanizeVariants } from '../shared/wall-title'; import { BACKEND_LABELS, BACKENDS } from '../shared/supported-backends'; import type { ChartEntry } from '../shared/chart-categories'; @@ -37,6 +39,8 @@ export function ChartCodeModal({ editorIndices?: number[]; onClose: () => void; }) { + const { t } = useTranslation(); + const { locale } = useLocale(); const [index, setIndex] = useState(() => Math.min(Math.max(initialIndex, 0), Math.max(tests.length - 1, 0)), ); @@ -48,7 +52,7 @@ export function ChartCodeModal({ const testCase = tests[index]; - const titles = useMemo(() => humanizeVariants(tests), [tests]); + const titles = useMemo(() => humanizeVariants(tests, locale), [locale, tests]); // Switching examples resets the temporary overrides. useEffect(() => setTempOptions({}), [index]); @@ -173,7 +177,7 @@ export function ChartCodeModal({ @@ -236,7 +240,7 @@ export function ChartCodeModal({ <>
diff --git a/site/src/components/ChartRedesignFigure.tsx b/site/src/components/ChartRedesignFigure.tsx new file mode 100644 index 00000000..a2c2e676 --- /dev/null +++ b/site/src/components/ChartRedesignFigure.tsx @@ -0,0 +1,180 @@ +import { useEffect, useRef } from 'react'; +import type { ChartAssemblyInput } from 'flint-chart'; +import { FlintAppInner } from '../../../packages/flint-mcp/ui/src/FlintApp'; +import foodPrices from '../data/cpi-food-prices.json'; +import './chart-redesign-figure.css'; + +const trendValues = foodPrices.values.map(({ month, item, price }) => ({ + month, + item, + price, +})); + +const foodNames = [...new Set(foodPrices.values.map(({ item }) => item))]; +const changesByFood = new Map>(); +for (const { month, item, annualChange } of foodPrices.values) { + if (annualChange === null) continue; + const series = changesByFood.get(item) ?? new Map(); + series.set(month, annualChange); + changesByFood.set(item, series); +} + +function pearsonCorrelation(left: Map, right: Map): number { + const pairs = [...left].flatMap(([month, leftValue]) => { + const rightValue = right.get(month); + return rightValue === undefined ? [] : [[leftValue, rightValue] as const]; + }); + const leftMean = pairs.reduce((sum, [value]) => sum + value, 0) / pairs.length; + const rightMean = pairs.reduce((sum, [, value]) => sum + value, 0) / pairs.length; + let covariance = 0; + let leftVariance = 0; + let rightVariance = 0; + for (const [leftValue, rightValue] of pairs) { + const leftDelta = leftValue - leftMean; + const rightDelta = rightValue - rightMean; + covariance += leftDelta * rightDelta; + leftVariance += leftDelta ** 2; + rightVariance += rightDelta ** 2; + } + const denominator = Math.sqrt(leftVariance * rightVariance); + return denominator === 0 ? 0 : covariance / denominator; +} + +const correlationValues = foodNames.flatMap((rowFood) => + foodNames.map((columnFood) => ({ + rowFood, + columnFood, + correlation: pearsonCorrelation( + changesByFood.get(rowFood) ?? new Map(), + changesByFood.get(columnFood) ?? new Map(), + ), + })), +); + +type RedesignVariant = 'sparkline' | 'heatmap'; + +function chartInput(variant: RedesignVariant, transformed: boolean): ChartAssemblyInput { + if (variant === 'sparkline') { + return { + data: { values: trendValues }, + semantic_types: { + month: 'YearMonth', + item: 'Category', + price: { semanticType: 'Price', unit: 'USD' }, + }, + chart_spec: { + chartType: transformed ? 'Sparkline' : 'Line Chart', + encodings: { + x: { field: 'month' }, + y: { field: 'price' }, + color: { field: 'item' }, + }, + baseSize: { width: 400, height: 300 }, + canvasSize: { width: 400, height:400 }, + }, + field_display_names: { month: 'Month', item: 'Food', price: 'Average price' }, + }; + } + + return { + data: { values: correlationValues }, + semantic_types: { + rowFood: 'Category', + columnFood: 'Category', + correlation: 'Correlation', + }, + chart_spec: { + chartType: 'Heatmap', + encodings: { + x: { field: 'columnFood' }, + y: { field: 'rowFood' }, + color: { field: 'correlation' }, + }, + chartProperties: { showTextLabels: transformed }, + baseSize: { width: 390, height: 300 }, + canvasSize: { width: 390, height: 300 }, + }, + field_display_names: { + rowFood: 'Food', + columnFood: 'Food', + correlation: 'Price correlation', + }, + }; +} + +const mockApp = { + sendMessage: async () => undefined, +}; + +function McpView({ + variant, + transformed, + showInteraction = false, +}: { + variant: RedesignVariant; + transformed: boolean; + showInteraction?: boolean; +}) { + const rootRef = useRef(null); + const menuOpenRequestedRef = useRef(false); + + useEffect(() => { + if (!showInteraction || !rootRef.current) return; + const root = rootRef.current; + const markTarget = () => { + if (variant === 'sparkline') { + const options = root.querySelectorAll('.tc-opt'); + for (const option of options) { + if (option.textContent?.trim() === 'Sparkline') option.classList.add('redesign-pointer-target'); + } + } else { + const controls = root.querySelectorAll('.opt'); + for (const control of controls) { + if (control.querySelector('.opt-label')?.textContent?.trim() === 'Labels') { + control.classList.add('redesign-pointer-target', 'redesign-property-target'); + } + } + } + }; + const observer = new MutationObserver(markTarget); + observer.observe(root, { childList: true, subtree: true }); + markTarget(); + + const trigger = variant === 'sparkline' + ? root.querySelector('.tc-type') + : null; + if (trigger && !menuOpenRequestedRef.current && trigger.getAttribute('aria-expanded') !== 'true') { + menuOpenRequestedRef.current = true; + trigger.click(); + } + return () => observer.disconnect(); + }, [showInteraction, variant]); + + return ( +
+ +
+ ); +} + +function RedesignExample({ variant }: { variant: RedesignVariant }) { + return ( +
+ + + +
+ ); +} + +export function ChartRedesignFigure() { + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/site/src/components/GalleryOptionsBar.tsx b/site/src/components/GalleryOptionsBar.tsx index 163ce452..5d90a356 100644 --- a/site/src/components/GalleryOptionsBar.tsx +++ b/site/src/components/GalleryOptionsBar.tsx @@ -10,246 +10,365 @@ * JSON but never change any underlying state. There is no "Copy spec to chat" * button. */ -import type { CSSProperties } from 'react'; +import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; import type { ChartOption } from 'flint-chart'; import { siteTheme } from '../shared/theme'; +import { chartIconFor } from '../shared/chart-categories'; import type { ControlSpec, PanelModel, ResolvedAction } from '../shared/chart-options'; +import './gallery-options-bar.css'; /** Stable string key for an arbitrary option value (handles undefined/objects). */ function valueKey(value: unknown): string { return JSON.stringify(value ?? null); } -const labelStyle: CSSProperties = { - color: siteTheme.textMuted, - fontSize: 12, - minWidth: 0, - maxWidth: 160, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}; - -const READOUT_WIDTH = 44; +/** Trim a trailing "(hint)" and clip long labels for the compact select. */ +function compactSelectLabel(label: string): string { + const withoutHint = label.replace(/\s*\([^)]*\)\s*$/u, '').trim(); + if (withoutHint.length <= 16) return withoutHint; + return `${withoutHint.slice(0, 13).trimEnd()}...`; +} -// Best-effort sizing: measure each option by its label length + the intrinsic -// width of its widget, then snap to a small set of tiers. This keeps the strip -// looking grid-like (only a few distinct widths) while letting compact controls -// (toggles) stay narrow and roomy ones (sliders/selects) get the space they need. -const LABEL_CHAR_PX = 6.6; -const LABEL_MAX_PX = 132; -const LABEL_GAP = 8; -const WIDGET_PX: Record = { - continuous: 72 + 6 + READOUT_WIDTH, // slider track + gap + readout - discrete: 128, // select - binary: 30, // toggle - pivot: 96, // stepper -}; -const WIDTH_TIERS = [140, 168, 200, 232, 264, 296]; +function DiscreteControl(props: { + label: string; + options: { value: unknown; label: string }[]; + selectedIndex: number; + onChange: (value: unknown) => void; +}) { + const { label, options, selectedIndex, onChange } = props; + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const selected = options[selectedIndex]; -function optionWidth(label: string, kind: string): number { - const labelPx = Math.min(LABEL_MAX_PX, Math.ceil(label.length * LABEL_CHAR_PX)); - const needed = labelPx + LABEL_GAP + (WIDGET_PX[kind] ?? 120); - return WIDTH_TIERS.find((t) => t >= needed) ?? WIDTH_TIERS[WIDTH_TIERS.length - 1]; -} + 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]); -function optStyleFor(_width: number): CSSProperties { - return { - display: 'flex', - alignItems: 'center', - gap: 14, - margin: 0, - minWidth: 0, - // Each cell hugs its own label + widget and left-aligns (no fixed tier width): - // the label sits immediately left of the control with a single gap, and the - // flex-wrap strip flows cells left-to-right, wrapping when they overflow. - flex: '0 0 auto', - }; + return ( +
{ + if (event.key === 'Escape' && open) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } + }} + > + + {open && ( +
    + {options.map((option, index) => { + const isSelected = index === selectedIndex; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); } -const controlInlineStyle: CSSProperties = { - display: 'grid', - gridTemplateColumns: `72px auto`, - alignItems: 'center', - gap: 6, - minWidth: 0, -}; - function ControlRow(props: { label: string; spec: ControlSpec; value: unknown; - width: number; onChange: (value: unknown) => void; }) { - const { label, spec, value, width, onChange } = props; + const { label, spec, value, onChange } = props; + + if (spec.type === 'discrete') { + const current = valueKey(value); + const index = spec.options.findIndex((option) => valueKey(option.value) === current); + return ( + + ); + } let control: React.ReactNode = null; if (spec.type === 'continuous') { const step = spec.step ?? ((spec.max - spec.min) / 100 || 1); const num = typeof value === 'number' ? value : spec.min; + const pct = spec.max > spec.min ? ((num - spec.min) / (spec.max - spec.min)) * 100 : 0; control = ( - + onChange(Number(e.target.value))} - style={{ width: '100%', minWidth: 0, accentColor: siteTheme.accent }} /> - - {Number(num).toLocaleString()} - + {Number(num).toLocaleString()} ); - } else if (spec.type === 'discrete') { - const current = valueKey(value); - const idx = spec.options.findIndex((o) => valueKey(o.value) === current); - control = ( - - ); } else { - const on = Boolean(value); control = ( - + ); } return ( -