diff --git a/.github/release-notes/0.5.0.md b/.github/release-notes/0.5.0.md
new file mode 100644
index 00000000..588651b7
--- /dev/null
+++ b/.github/release-notes/0.5.0.md
@@ -0,0 +1,127 @@
+# Flint 0.5.0: Formal visual themes
+
+Flint 0.5 introduces `ThemeSpec`, a formal specification for applying a
+coherent design system across an entire chart library. Instead of requiring
+creators or agents to reproduce design guidance chart by chart, a ThemeSpec
+participates directly in compilation and adapts to the chart's semantics,
+data, cardinality, and available space.
+
+A ThemeSpec shapes compilation in three stages. Using resolved semantic
+information, it:
+
+- governs layout constraints and dynamics, including density, spacing, sizing,
+ and stretch limits;
+- conditionally resolves visual preferences for labels, legends, axes,
+ annotations, and semantic roles; and
+- supplies design fixtures such as typography, color, surfaces, mark geometry,
+ and structural line styles to backend-specific code generation.
+
+
+
+
+ ThemeSpec participates throughout compilation, from resolved semantics and layout dynamics to conditional visual preferences and backend design fixtures.
+
+
+This release also adds ten built-in themes, custom and inherited ThemeSpecs,
+public theme APIs, a visual-theme explorer and Theme Lab, plus theme discovery
+and authoring support in the Flint MCP server and MCP App.
+
+## Use a visual theme
+
+Add `theme_spec` beside `chart_spec`. The chart spec continues to define what
+the chart means; the theme defines how that meaning is presented.
+
+```json
+{
+ "chart_spec": {
+ "chartType": "Bar Chart",
+ "encodings": {
+ "x": { "field": "region" },
+ "y": { "field": "revenue" }
+ }
+ },
+ "theme_spec": "economist"
+}
+```
+
+## Ten built-in themes
+
+Flint ships New York Times, Economist, Swiss, Nature, McKinsey, Datawrapper,
+Power BI, Power BI Light, Pop, and Cartoon presets. The
+[visual-theme explorer](https://microsoft.github.io/flint-chart/#/themes)
+applies each preset to the same set of charts for direct comparison.
+
+
+
+
+
+
+ Economist — compact editorial graphics with a strong red accent.
+
+
+
+
+
+
+
+ Swiss — typographic structure, restrained color, and a clear visual grid.
+
+
+
+
+
+
+
+ Pop — bold color, emphatic marks, and playful graphic contrast.
+
+
+## Create a brand theme
+
+Pass a custom `ThemeSpec`, or inherit a built-in preset and override only the
+decisions that should differ:
+
+```json
+{
+ "theme_spec": {
+ "extends": "economist",
+ "id": "our-brand",
+ "ink": {
+ "series": {
+ "single": "#6b3fa0"
+ }
+ }
+ }
+}
+```
+
+Nested objects merge; arrays and scalar values replace inherited values.
+
+## Tools and integrations
+
+- The [visual-theme explorer](https://microsoft.github.io/flint-chart/#/themes)
+ compares presets across the same chart wall.
+- [Theme Lab](https://microsoft.github.io/flint-chart/#/theme-lab) tests custom
+ ThemeSpecs across chart types and data shapes.
+- The MCP server and MCP App add theme discovery, preset selection, and custom
+ ThemeSpec support through `list_themes`.
+- The bundled `flint://theme-skill` resource and `author_flint_theme` prompt
+ help agents translate design guidance into reusable ThemeSpecs.
+- Public APIs include `ThemeSpec`, `ThemePreset`, `THEME_PRESETS`,
+ `listThemePresets()`, and `resolveThemeSpec()`.
+- The theme explorer, Theme Lab, and authoring guidance are available in
+ English and Chinese.
+
+The release also improves Vega-Lite logarithmic tick and grid spacing, line
+endpoint guides, and heatmap grid treatment.
+
+ThemeSpec is currently realized by the Vega-Lite backend. Other backends still
+accept the shared Flint input but do not yet apply `theme_spec`. Existing inputs
+without a theme retain Flint's default behavior.
+
+See [Using themes](https://microsoft.github.io/flint-chart/#/documentation/theme-spec)
+for the complete vocabulary and examples.
+
+See the [changelog](https://github.com/microsoft/flint-chart/blob/main/CHANGELOG.md)
+for the complete technical summary.
+
+**Full Changelog**: https://github.com/microsoft/flint-chart/compare/0.4.0...0.5.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50204786..ab598583 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.5.0] - 2026-08-05
+
+### Added
+
+- Formal visual themes for Vega-Lite through the new top-level `theme_spec`
+ field. Callers can select one of ten built-in presets, provide a custom
+ `ThemeSpec`, or inherit a preset with `extends` and override selected fields.
+ Nested objects merge while arrays and scalar values replace inherited values.
+- The `pop` preset, a high-energy extension of Swiss with process colors,
+ strong structure, and chart-aware grid and heatmap treatment.
+- A semantic theme-grounding system that applies layout behavior, presentation
+ rules, mark geometry, typography, color, labels, legends, axes, annotations,
+ and chart furniture as one visual system across chart types and data shapes.
+- Public theme APIs: `ThemeSpec`, `ThemePreset`, `THEME_PRESETS`,
+ `listThemePresets()`, and `resolveThemeSpec()`.
+- Theme discovery in the MCP server through `list_themes`, plus preset selection
+ in the interactive MCP App.
+- Bundled ThemeSpec authoring guidance through the `flint://theme-skill`
+ resource and `author_flint_theme` prompt. Custom ThemeSpecs remain available
+ in the MCP App while callers compare presets, without becoming global themes.
+- A public visual-theme explorer with regular grid and screenshot-friendly
+ scattered-poster layouts, a compact two-row banner composition, large
+ chart/spec previews, a complete **Using themes** guide, and
+ preset/custom/inherited live examples on the Flint project site.
+- Theme Lab, an interactive editor for authoring a ThemeSpec and testing it
+ against a diverse wall of charts, with built-in Signal Studio, Microsoft
+ Fluent, and People's Daily examples.
+- Complete English and Chinese localization for the public theme explorer,
+ Theme Lab, navigation, and MCP theme-authoring guidance.
+
+### Changed
+
+- Vega-Lite assembly now grounds the selected theme before layout and realizes
+ its decisions throughout compilation instead of applying a post-render style
+ layer. Existing inputs without `theme_spec` retain Flint's default behavior.
+- Vega-Lite logarithmic axes choose readable powers-of-ten or 1/2/5 tick and
+ grid spacing from the transformed scale span and available pixels on either
+ axis. Two-position line axes suppress asymmetric endpoint guides, while
+ heatmaps use cell boundaries instead of redundant axis grids.
+
## [0.4.1] - 2026-07-27
### Changed
@@ -178,7 +218,8 @@ 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.4.1...HEAD
+[Unreleased]: https://github.com/microsoft/flint-chart/compare/0.5.0...HEAD
+[0.5.0]: https://github.com/microsoft/flint-chart/compare/0.4.0...0.5.0
[0.4.1]: https://github.com/microsoft/flint-chart/compare/0.4.0...0.4.1
[0.4.0]: https://github.com/microsoft/flint-chart/compare/0.3.0...0.4.0
[0.3.0]: https://github.com/microsoft/flint-chart/compare/88fbeb5ebf07f18a1cf661ebef71cc570b7425d6...0.3.0
diff --git a/README.md b/README.md
index 32a93f33..3699da4e 100644
--- a/README.md
+++ b/README.md
@@ -6,19 +6,20 @@
[](https://github.com/microsoft/flint-chart/actions/workflows/ci.yml)
[](LICENSE)
-**Please visit:** [**Flint Project Site**](https://microsoft.github.io/flint-chart/) | [**MCP Server Guide**](https://microsoft.github.io/flint-chart/#/mcp) | [**中文主页**](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**.
-Instead of asking agents or developers to tune verbose chart configuration
-details such as scales, axes, spacing, labels, and layout, the Flint compiler
-derives optimized chart settings from the data, semantic types, chart type, and
-encodings. The result is a compact chart specification that agents can produce
-reliably, people can edit directly, and multiple backends can render as native
+**Please visit:** [**Flint Project Site**](https://microsoft.github.io/flint-chart/) | [**Visual Themes**](https://microsoft.github.io/flint-chart/#/themes) | [**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 turn
+simple, human-editable chart specs into expressive, polished visualizations**.
+Rather than requiring agents or developers to tune verbose settings for scales,
+axes, spacing, labels, and layout, Flint derives those decisions from the data,
+semantic types, chart type, encodings, and an optional visual theme. Users can
+use a compact spec to create visually polished, brand-consistent charts rendered
+as native
[Vega-Lite](https://vega.github.io/vega-lite/),
[ECharts](https://echarts.apache.org/),
-[Chart.js](https://www.chartjs.org/), or
-[Plotly](https://plotly.com/javascript/) specs, and native Excel charts through Office.js.
+[Chart.js](https://www.chartjs.org/),
+[Plotly](https://plotly.com/javascript/) specs, or as native Excel charts
+through Office.js.
This repo contains two main components:
@@ -38,6 +39,9 @@ This repo contains two main components:
semantic types such as `Rank`, `Temperature`, `Price`, or `Country`.
- **Automatic layout.** Flint adapts sizing, spacing, labels, marks, and legends
to the data cardinality, chart design, and canvas constraints.
+- **Formal visual themes.** Define layout behavior, semantic presentation, and
+ visual identity once, then apply them across a chart library with a preset,
+ custom `ThemeSpec`, or inherited theme.
- **Multiple backends.** Compile one input to backend-native output across
[Vega-Lite](https://vega.github.io/vega-lite/),
[ECharts](https://echarts.apache.org/),
@@ -49,6 +53,11 @@ This repo contains two main components:
## Updates
+- **August 5, 2026** — Flint 0.5.0 introduces a [formal theme specification](https://microsoft.github.io/flint-chart/#/themes)
+ that allows designers and users to define a visual system once and apply it
+ consistently across an entire chart library. It includes ten presets: New
+ York Times, Economist, Swiss, Nature, McKinsey, Datawrapper, Power BI, Power
+ BI Light, Pop, and Cartoon. ([v0.5.0](https://github.com/microsoft/flint-chart/releases/tag/0.5.0))
- **July 24, 2026** — Flint 0.4.0 adds 38 Plotly chart types and 18 native,
editable Excel chart templates. ([v0.4.0](https://github.com/microsoft/flint-chart/releases/tag/0.4.0))
- **July 19, 2026** — Flint 0.3.0 adds dynamic chart widgets that switch chart
@@ -110,6 +119,67 @@ const plotlyFigure = assemblePlotly(input);
const excelArtifact = assembleExcel(input);
```
+## Apply Visual Themes
+
+`theme_spec` sits beside `chart_spec`: the chart spec defines what the chart
+means, while the theme defines how that meaning is presented. A theme can guide
+layout, labels, legends, axes, mark geometry, typography, and color as one
+coherent visual system.
+
+Use one of Flint's ten built-in presets:
+
+```ts
+const themedSpec = assembleVegaLite({
+ ...input,
+ theme_spec: 'economist',
+});
+```
+
+Or inherit a preset and override only the decisions that belong to your brand:
+
+```ts
+const brandedSpec = assembleVegaLite({
+ ...input,
+ theme_spec: {
+ extends: 'economist',
+ id: 'our-brand',
+ ink: {
+ series: { single: '#6b3fa0' },
+ },
+ },
+});
+```
+
+Nested objects merge; arrays and scalar values replace the inherited value.
+ThemeSpec currently affects Vega-Lite output. Compare all presets on the
+[theme explorer](https://microsoft.github.io/flint-chart/#/themes). See
+[Using themes](docs/theme-spec.md) for the complete custom and inherited-theme
+reference.
+
+
+
+
+
+
+ Economist — compact editorial graphics with a strong red accent.
+
+
+
+
+
+
+
+ Swiss — typographic structure, restrained color, and a clear visual grid.
+
+
+
+
+
+
+
+ Pop — bold color, emphatic marks, and playful graphic contrast.
+
+
See the [API reference](docs/api-reference.md), backend references for
[Vega-Lite](docs/reference-vegalite.md), [ECharts](docs/reference-echarts.md),
[Chart.js](docs/reference-chartjs.md), [Plotly](docs/reference-plotly.md), and
@@ -128,7 +198,7 @@ For setup, start with the
includes client configuration, usage examples, and links to deeper references.
-
+
MCP calls let agents embed rows directly as `data.values`, or read local JSON,
@@ -140,19 +210,26 @@ use the standalone [agent skill](agent-skills/flint-chart-author/SKILL.md).
```
flint-chart/
├── packages/
-│ ├── flint-js/ npm package `flint-chart` (TypeScript)
+│ ├── flint-js/ npm package `flint-chart` (TypeScript)
│ │ └── src/
-│ │ ├── core/ semantics, layout, decisions, shared types
-│ │ ├── vegalite/ Vega-Lite backend
-│ │ ├── echarts/ ECharts backend
-│ │ ├── chartjs/ Chart.js backend
-│ │ └── test-data/ fixtures + generators (drive tests and the gallery)
-│ ├── flint-py/ Python port preview (package to be released)
-│ └── flint-mcp/ npm package `flint-chart-mcp` (MCP render server)
-├── site/ Vite + React demo: landing, gallery, editor, docs
-├── agent-skills/ fallback copy of the MCP-served agent skill
-├── shared/test-data/ JSON fixtures shared across JS + Python
-└── docs/ architecture and design documents
+│ │ ├── core/ semantics, themes, layout, decisions, shared types
+│ │ ├── chart-types/ shared chart definitions and template metadata
+│ │ ├── vegalite/ Vega-Lite backend
+│ │ ├── echarts/ ECharts backend
+│ │ ├── chartjs/ Chart.js backend
+│ │ ├── plotly/ Plotly backend
+│ │ ├── excel/ native Excel backend
+│ │ ├── gallery/ gallery assembly and generated references
+│ │ └── test-data/ fixtures and stress-test generators
+│ ├── flint-mcp/ MCP server, MCP App UI, assets, and tests
+│ └── flint-py/ Python port preview (package to be released)
+├── site/ Vite + React project site, gallery, editor, and docs
+├── agent-skills/ chart- and theme-authoring skills for agents
+├── agents/ agent and MCP server configuration
+├── shared/test-data/ JSON fixtures shared across JS and Python
+├── scripts/ reference generation and theme audit tooling
+├── docs/ user, API, backend, and architecture documentation
+└── design-docs/ design proposals and implementation research
```
### Documentation
@@ -160,6 +237,7 @@ flint-chart/
The [project site](https://microsoft.github.io/flint-chart/) is the main entry
point for examples, the live editor, and concept docs. For source-level
references, start with the [API reference](docs/api-reference.md), the
+[theme guide](docs/theme-spec.md), the
[Flint MCP project page](https://microsoft.github.io/flint-chart/#/mcp), or the
[Development guide](docs/DEVELOPMENT.md). See the [changelog](CHANGELOG.md) for
notable changes in each release.
diff --git a/agent-skills/README.md b/agent-skills/README.md
index 5a557cdb..1c739253 100644
--- a/agent-skills/README.md
+++ b/agent-skills/README.md
@@ -1,8 +1,14 @@
# agent-skills/
-Agent skill for **flint-chart** — teaches LLMs and IDE agents how to produce
-correct, idiomatic `ChartAssemblyInput` JSON, then use it in the right
-workflow: MCP rendering, project integration, or backend compilation.
+Agent skills for **flint-chart** teach LLMs and IDE agents how to author
+portable chart and theme specifications.
+
+- [flint-chart-author/SKILL.md](flint-chart-author/SKILL.md) covers
+ `ChartAssemblyInput`, MCP rendering, project integration, and backend
+ compilation.
+- [flint-theme-author/SKILL.md](flint-theme-author/SKILL.md) translates brand
+ guidelines, websites, slide decks, and publication references into reusable
+ custom `ThemeSpec` JSON.
## How agents should use flint-chart
@@ -24,5 +30,5 @@ When the user wants more than a spec, the skill also tells the agent how to:
- call `assembleVegaLite`, `assembleECharts`, or `assembleChartjs` in JS/TS;
- use the Python package when it is published in a later release.
-See [flint-chart-author/SKILL.md](flint-chart-author/SKILL.md) for the full
-contract, worked examples, and the validation checklist.
+See the relevant skill for its full output contract, references, worked
+examples, and validation checklist.
diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md
index 3b3fbedc..daed293c 100644
--- a/agent-skills/flint-chart-author/SKILL.md
+++ b/agent-skills/flint-chart-author/SKILL.md
@@ -83,15 +83,19 @@ published, use the npm package or MCP server for released workflows.
interface ChartAssemblyInput {
// Bound by the HOST or by you, depending on the situation (see below).
data: { values: any[] } | { url: string };
- semantic_types?: Record; // field → semantic type ← you write this
+ semantic_types?: Record; // field → type ( ← you write this)
chart_spec: { // ← you write this
chartType: string; // e.g. "Scatter Plot"
+ title?: string; // the headline — write one
+ subtitle?: string; // what is measured, of whom, when, in what units
encodings: Record; // channel → { field, ... } (or array)
baseSize?: { width: number; height: number }; // target layout size, default 400×320
canvasSize?: { width: number; height: number }; // optional hard ceiling on stretch
chartProperties?: Record; // per-chart tuning (optional)
};
options?: Record; // global layout options (rarely needed)
+ field_display_names?: Record; // field → readable axis/legend title
+ theme_spec?: string | { extends: string; [key: string]: any }; // preset or preset override (Vega-Lite only)
}
```
@@ -167,6 +171,77 @@ For a Vega-Lite-specific style tweak:
This edited Vega-Lite spec is no longer a portable Flint spec. Do not send it to
`render_chart`; use `render_chart` only for Flint `ChartAssemblyInput`.
+## Write a headline
+
+Set `chart_spec.title` to the finding, in a sentence, and `chart_spec.subtitle`
+to the reading of it — what is measured, of whom, when, in what units:
+
+```
+title: "A pyramid that is no longer a pyramid"
+subtitle: "United States population by age and sex, 2020, millions"
+```
+
+`Jan`, `Cairo`, `Chrome` name their own kind; `26`, `5,300`, `0.42` do not, and
+the headline is where they get named. Leave it out only where the chart is not
+read on its own — a sparkline in a cell, a tile under its own caption. Nothing
+breaks: with no headline to lean on, the compiler keeps the axis titles instead.
+
+## Visual themes (`theme_spec`)
+
+Use one of two forms. Prefer a preset unless the user asks for a specific
+brand adjustment.
+
+### 1. Use a preset
+
+Call `list_themes` to choose an id, then place it beside `chart_spec`:
+
+```json
+{ "chart_spec": { ... }, "theme_spec": "economist" }
+```
+
+| id | what it is for |
+| --- | --- |
+| `nyt` | Newsroom graphics: headline states the finding, values on the marks, series named at their ends. |
+| `economist` | Print weekly: compact, flat headline over a deck, units repeated down the ruler. |
+| `swiss` | International Typographic Style: strong grid structure, black typography, and a focused red accent. |
+| `nature` | Journal figure: small panel, axis titles with units, statistics beside the fit. |
+| `mckinsey` | Consulting deck: wide bands, every value printed, headline states the takeaway. |
+| `datawrapper` | Embedded web chart: narrow column, plain headline and deck, rule under the footer. |
+| `powerbi` | Dashboard tile: compact, legend to the right, latest point emphasised. |
+| `powerbi-light` | Light dashboard tile: white canvas, fine gridlines, and bright categorical color. |
+| `cartoon` | Playful illustration: warm paper, rounded type, bold outlines, and bright color. |
+
+### 2. Override a preset
+
+Keep overrides narrow and state only what the user wants to change:
+
+```json
+{
+ "theme_spec": {
+ "extends": "economist",
+ "id": "our-brand",
+ "ink": {
+ "series": {
+ "single": "#6b3fa0"
+ }
+ }
+ }
+}
+```
+
+Common simple overrides are `ink.surface.canvas`, `ink.series.single`,
+`ink.series.categorical`, `type.headline.family`, and `layout.density`
+(`"compact"`, `"normal"`, or `"airy"`). If replacing
+`ink.series.categorical`, also replace `categoricalExtended` so charts with
+many series keep the requested brand palette.
+
+Do not copy an entire preset or invent theme keys. A theme controls
+presentation; fields, aggregation, filtering, and sorting still belong in the
+chart input. ThemeSpec currently affects Vega-Lite only.
+
+Full reference:
+https://microsoft.github.io/flint-chart/#/documentation/theme-spec
+
## Step 1 — pick `chartType`
Use one of the registered names **exactly**. Vega-Lite is the default and
@@ -250,8 +325,8 @@ support a subset (verify if targeting a non-VL backend):
`"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`,
`"Parallel Coordinates"`, `"Graph"`, `"Tree"`.
- **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar,
- Combo, Line, Bump, Area, Range Area, Pie, Doughnut, Histogram, Radar, Rose,
- Slope, Connected Scatter.
+ Lollipop, Bump, Combo, Line, Area, Range Area, Pie, Doughnut, Histogram,
+ Radar, Rose, Slope, Connected Scatter.
You do not need to call the library or inspect its source to author the
input — pick from this table.
@@ -333,6 +408,29 @@ What choosing well gets you (automatically):
If you don't know, use `Quantity` for numbers, `Category` for strings,
`Date`/`DateTime` for date-shaped values. Do **not** invent type names.
+### Saying more than the type name
+
+A field's entry can be an object instead of a string when the type alone
+understates what you know:
+
+```json
+"semantic_types": {
+ "anomaly": { "semanticType": "Quantity", "unit": "°C", "divergingMidpoint": 0 },
+ "rating": { "semanticType": "Score", "intrinsicDomain": [1, 5] }
+}
+```
+
+- `unit` — the unit or currency code: `"USD"`, `"°C"`, `"kg"`.
+- `intrinsicDomain` — the field's own bounds, for bounded scales only: `[1, 5]`
+ for a five-star rating, `[0, 100]` for a percentage score. Not for
+ open-ended measures.
+- `divergingMidpoint` — where the middle colour of a diverging scale sits.
+ Set it if you can tell what the reader is comparing against; leave it out if
+ you can't.
+- `sortOrder` — the order the categories should appear in, when the order in
+ the data is not the one you want and it isn't alphabetical either:
+ `["Low", "Medium", "High"]`. For a handful of categories, not a long list.
+
## Chart-level properties (`chartProperties`)
`chartProperties` is an optional per-chart tuning map. Set a property only
@@ -365,7 +463,8 @@ derived). Values are clamped to the ranges shown.
| 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 |
+| Waterfall | `showTextLabels` | boolean (false) | Legacy spelling of `showValueLabels`; still accepted |
+| Bar / Grouped Bar / Stacked Bar / Lollipop / Pyramid / Pie / Donut / Heatmap / Waterfall | `showValueLabels` | boolean | Print the numbers on the marks. Works with or without a theme: unset, it follows the house's own habit at this density (and with no house named, stays off), so the default the compiler reports is always the honest one. Set it to overrule that for one chart. Reported inapplicable (and ignored) where the marks are too dense to carry readable numbers, or where the template already writes its own text, so it is never a control that does nothing. On a stacked bar each segment prints its own value in the middle of the segment (at the edge it would read as the running total); segments too thin to hold a line of text go unlabelled, and a normalized stack prints each segment's share rather than its raw value, since the share is what the length shows. The printed number is rounded to roughly three significant figures — with a k/M suffix once the values get long, and enough decimals that the smallest value in the series still says something — so a raw `3.14159265` lands as `3.14` and a series of `0.001` to `5000` reads at both ends. Rounding never goes so far that two marks of different size print the same number, or that a non-zero value prints as `0`; where a house asked for a coarser precision than that, the digits are raised until the labels agree with the marks. |
| 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 |
@@ -395,6 +494,8 @@ default:
- **Sort a category axis by its measure:** `encodings.x = { field: "name", sortBy: "y", sortOrder: "descending" }`.
- **Pick a color scheme:** `encodings.color = { field: "region", scheme: "tableau10" }`.
- **Override an inferred type:** `encodings.x = { field: "year", type: "ordinal" }` (e.g. treat a year as discrete bands).
+- **Use readable field titles:** `field_display_names = { percentageOfCountries: "Percentage of countries" }`.
+ Keep encodings bound to the real column name; Flint uses the display name for axis titles and legend headers.
- **Resize the chart:** Flint sizes from two numbers — `baseSize` (the *target*
it aims for, default 400×320) and `canvasSize` (a *hard ceiling* it may never
exceed). With dense data the chart stretches from base toward the ceiling.
diff --git a/agent-skills/flint-theme-author/SKILL.md b/agent-skills/flint-theme-author/SKILL.md
new file mode 100644
index 00000000..4df9c637
--- /dev/null
+++ b/agent-skills/flint-theme-author/SKILL.md
@@ -0,0 +1,202 @@
+---
+name: flint-theme-author
+description: "Use when: creating, translating, refining, reviewing, or validating a custom Flint ThemeSpec from brand guidelines, websites, slide decks, publication references, design tokens, or an existing visual identity. Produce a reusable ThemeSpec JSON object for Flint Theme Lab without inventing fields or changing chart semantics."
+---
+
+# Flint ThemeSpec authoring
+
+Create a reusable visual system for Flint charts. Translate the user's reference
+materials into one valid `ThemeSpec` that can be pasted directly into Theme Lab.
+
+## Authoritative references
+
+Read these before authoring. When prose and source disagree, the TypeScript
+contract is authoritative.
+
+- Theme guide: https://github.com/microsoft/flint-chart/blob/main/docs/theme-spec.md
+- Exhaustive ThemeSpec types and allowed values: https://github.com/microsoft/flint-chart/blob/main/packages/flint-js/src/core/theme/types.ts
+- Real preset implementations: https://github.com/microsoft/flint-chart/tree/main/packages/flint-js/src/core/theme/presets
+- Theme Lab: https://microsoft.github.io/flint-chart/#/theme-lab
+
+Do not invent keys. If a requested decision cannot be represented by
+`ThemeSpec`, omit it and briefly identify the unsupported decision outside the
+JSON only when the user asked for an explanation.
+
+## Output contract
+
+Unless the user asks for commentary, return exactly one valid JSON object:
+
+- Return the bare `ThemeSpec`, not `{ "theme_spec": ... }` and not a complete
+ `ChartAssemblyInput`.
+- Do not wrap JSON in a Markdown fence.
+- Do not include comments, placeholders, ellipses, or trailing commas.
+- Include a stable kebab-case `id` and a human-readable `label`.
+- Include only supported fields and only decisions justified by the references.
+- Omit unspecified optional fields rather than guessing.
+
+The top-level output schema is:
+
+```json
+{
+ "extends": "optional-preset-id",
+ "id": "theme-id",
+ "label": "Theme label",
+ "ink": {},
+ "type": {},
+ "structure": {},
+ "marks": {},
+ "labels": {},
+ "legend": {},
+ "dataLabels": {},
+ "annotation": {},
+ "furniture": [],
+ "facets": {},
+ "layout": {},
+ "chartDefaults": {},
+ "compileDefaults": {},
+ "interaction": {},
+ "variants": []
+}
+```
+
+Every block except `id` and `label` may be omitted. The skeleton shows the
+shape, not a requirement to emit empty blocks.
+
+## Theme boundary
+
+A theme controls presentation and compiler behavior across many charts. It may
+decide color, typography, surfaces, grids, axes, mark geometry, labels,
+legends, annotations, density, spacing, facets, and reusable chart defaults.
+
+A theme does not choose data fields, encodings, aggregation, filtering,
+sorting, chart titles, or data values. Never put those decisions in a
+`ThemeSpec`. Outside `chartDefaults`, authored policy must not name a chart
+type, positional channel, mark type, field, or backend property.
+
+## Workflow
+
+1. Inspect the supplied references. Extract recurring decisions, not isolated
+ decoration from one screenshot.
+2. Ask only for missing decisions that materially affect the theme:
+ identity, required colors, typography, surface/background, density,
+ accessibility constraints, and whether an existing Flint preset is a useful
+ base.
+3. Separate evidence into system roles:
+ - surfaces and text hierarchy;
+ - categorical, sequential, diverging, and status color;
+ - typography roles;
+ - axes, grids, baselines, frames, and structural ink;
+ - mark weight, corners, points, separators, and spacing;
+ - labels, legends, annotations, facets, and layout behavior.
+4. Choose standalone authoring or inheritance.
+5. Author the smallest coherent spec that expresses the system.
+6. Check every key and enum against `types.ts`.
+7. Return the bare JSON object.
+
+## Choosing inheritance
+
+Use `extends` when a built-in preset already supplies the intended compiler
+behavior and the new theme is a focused variation. Available preset IDs are:
+
+`nyt`, `economist`, `swiss`, `nature`, `mckinsey`, `datawrapper`, `powerbi`,
+`powerbi-light`, `pop`, and `cartoon`.
+
+Nested objects merge. Arrays and scalar values replace the preset value. If you
+replace `ink.series.categorical`, also consider replacing
+`categoricalExtended`; otherwise high-cardinality charts may return to the
+base preset's extended palette.
+
+Use a standalone theme when the reference system does not honestly inherit a
+preset's layout, typography, and presentation behavior.
+
+## Schema map
+
+Use the source contract for exhaustive nested fields and enum values. These are
+the authored blocks and their jobs:
+
+| Block | Purpose |
+| --- | --- |
+| `ink` | Canvas, plot, panel, text, structural, series, status, and accent colors |
+| `type` | Minimum size and role-based typography for headlines, axes, values, annotations, footnotes, and displays |
+| `structure` | Semantic categorical/measure axes, grids, baseline, and frame |
+| `marks` | Band width, strokes, interpolation, opacity, corners, outlines, points, connectors, intervals, summaries, and separators |
+| `labels` | Truncation, flush behavior, and angle policy |
+| `legend` | Visibility, ordered placement choices, direction, title, swatches, and redundancy suppression |
+| `dataLabels` | Visibility, placement, and ink behavior for values on marks |
+| `annotation` | Units, axis titles, number formatting, point emphasis, labels, and statistics |
+| `furniture` | Repeating masthead tabs and header/footer rules |
+| `facets` | Headers, frames, axis repetition, spacing, columns, and scale sharing |
+| `layout` | Density, target width, title block, and band step |
+| `chartDefaults` | Optional defaults keyed by registered chart type or `*`; caller values still win |
+| `compileDefaults` | Preferred base size, canvas size, and supported assemble options |
+| `interaction` | Tooltip format |
+| `variants` | Conditional policy adaptations; variants may not change `ink` or `type` |
+
+### High-value nested shapes
+
+```json
+{
+ "ink": {
+ "surface": { "source": "house", "canvas": "#ffffff", "plot": "#ffffff", "panel": "#f5f5f5" },
+ "text": { "primary": "#111111", "secondary": "#444444", "muted": "#777777", "inverse": "#ffffff" },
+ "structure": { "axis": "#222222", "grid": "#dddddd", "frame": "#cccccc", "rule": "#222222", "zero": "#222222", "connector": "#888888" },
+ "series": {
+ "single": "#0067b8",
+ "categorical": ["#0067b8", "#d83b01", "#107c10"],
+ "categoricalExtended": ["#0067b8", "#d83b01", "#107c10", "#5c2d91"],
+ "overflow": "#777777",
+ "sequential": { "stops": ["#e8f3fb", "#0067b8"], "space": "lab", "endpointsAgainstSurface": true },
+ "diverging": { "stops": ["#0067b8", "#f5f5f5", "#d83b01"], "neutral": "#f5f5f5", "space": "lab", "endpointsAgainstSurface": true },
+ "status": { "positive": "#107c10", "negative": "#d83b01", "neutral": "#777777" }
+ },
+ "accent": "#0067b8"
+ }
+}
+```
+
+This is a shape example, not a palette recommendation. Derive actual values
+from the user's references.
+
+Typography roles accept `family`, `size`, `weight`, `style`, `case`, and
+`color`. Allowed weights are `regular`, `medium`, `semibold`, and `bold`.
+Use available font families or defensible fallback stacks; do not claim a font
+is available merely because it appears in an image.
+
+Presence-driven policies use `omit`, `hairline`, `quiet`, `full`, or
+`emphasised`. Density uses `compact`, `normal`, or `airy`. Read `types.ts`
+before using less common enums.
+
+## Translation guidance
+
+- Map brand neutrals to surfaces, text hierarchy, and structure before mapping
+ accent colors to data series.
+- Treat categorical colors as an ordered identity set. Keep adjacent colors
+ distinguishable and ensure the palette remains legible on the plot surface.
+- Build sequential ramps with ordered lightness. Build diverging ramps around a
+ meaningful neutral. Do not use categorical arrays as numeric ramps.
+- Use status colors only for semantic positive, negative, and neutral meaning.
+- Preserve readable contrast for primary text, axes, labels, and values.
+- Translate recurring geometry into `marks`; do not encode a one-off shape from
+ a single chart as global policy.
+- Prefer semantic structure (`measure` versus `category`) over physical x/y
+ assumptions.
+- Keep the system coherent across line, bar, area, pie, matrix, distribution,
+ multiseries, and faceted charts.
+- Use `variants` only for real conditional policy and include `because` to state
+ why the adaptation exists.
+
+## Validation checklist
+
+Before returning the JSON, verify:
+
+- It parses as strict JSON.
+- It is a bare object with `id` and `label`.
+- Every top-level and nested key exists in `types.ts`.
+- Every enum value and value type matches the source contract.
+- It contains no chart data, fields, encodings, titles, or backend JSON.
+- Text and structural ink remain readable against their surfaces.
+- Categorical colors are distinguishable; ramps have ordered lightness and
+ usable endpoints.
+- Inherited arrays are intentionally replaced.
+- The theme expresses reusable rules rather than one chart's decoration.
+- The result can be pasted directly into Flint Theme Lab.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 2c52ddb6..0aa9c3de 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -68,5 +68,7 @@ Start with the guide that matches the surface you want to extend:
## Test coverage
- **Smoke tests:** `packages/flint-js/tests/smoke.test.ts`
-- **Visual coverage:** [Gallery](/gallery), driven by `TEST_GENERATORS` in test-data
+- **Complete visual coverage:** [Full test cases](/playground/full-test-cases), driven by `TEST_GENERATORS` in `packages/flint-js/src/test-data/`
+- **Curated examples:** [Gallery](/gallery)
- **Shared fixtures:** `shared/test-data/`, consumed by JS and Python tests
+- **Coverage matrices and authoring workflow:** [Chart engine test plan](/documentation/test-plan)
diff --git a/docs/adding-a-backend.md b/docs/adding-a-backend.md
index 5265869b..c0afb56d 100644
--- a/docs/adding-a-backend.md
+++ b/docs/adding-a-backend.md
@@ -111,9 +111,13 @@ Register in `templates/index.ts`: import defs, add them to the category map, and
# §5 Site and gallery
- **Gallery dev server:** `npm run site` from the repo root, then open `/gallery`
+- **Full visual matrix:** open `/playground/full-test-cases`; it renders every generator registered in `packages/flint-js/src/test-data/index.ts`
- **Supported backends:** update `site/src/shared/supported-backends.ts` if the new backend should appear in the UI
- **Renderers:** only add a new React view (`site/src/components/`) when the spec format cannot reuse `VegaLiteView`, `EChartsView`, or `ChartjsView`. `TripleChart` currently covers VL + ECharts + Chart.js.
+Use the [Chart engine test plan](/documentation/test-plan) to choose normal,
+semantic, density, and edge-case coverage for backend bring-up.
+
Optional: wire the assembler into `agent-skills/mcp-server/` if MCP clients should be able to call it.
---
@@ -134,5 +138,6 @@ A backend is ready when:
# §7 Related
- [Extending chart templates](/documentation/adding-a-chart-template) — `ChartTemplateDef` authoring
+- [Chart engine test plan](/documentation/test-plan) — shared cases and backend bring-up coverage
- [Auto Layout Algorithm](/documentation/layout-model) — what `computeLayout()` expects
- [API reference](/documentation/api-reference) — `ChartAssemblyInput` and assembler entry points
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 3ac68882..9473e74b 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -116,6 +116,8 @@ interface ChartAssemblyInput {
semantic_types?: Record;
chart_spec: {
chartType: string;
+ title?: string; // headline
+ subtitle?: string; // deck: what is measured, of whom, when, in what units
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
@@ -137,11 +139,30 @@ interface ChartAssemblyInput {
Maps column name → semantic type. This drives encoding type, formatting, aggregation defaults, color class, and layout. See [Semantic Type](/documentation/semantic-types).
+### `field_display_names`
+
+Maps raw column names to readable presentation labels used for axis titles and
+legend headers. Keep encodings bound to the original field names:
+
+```ts
+{
+ field_display_names: {
+ percentageOfCountries: 'Percentage of countries'
+ },
+ chart_spec: {
+ chartType: 'Bar Chart',
+ encodings: { x: 'country', y: 'percentageOfCountries' }
+ }
+}
+```
+
### `chart_spec`
| Field | Description |
|-------|-------------|
| `chartType` | Template name — must match a backend registry entry (`"Bar Chart"`, `"Heatmap"`, …) |
+| `title` | The headline. Write one: `Jan` and `Cairo` name their own kind, `26` and `5,300` do not, and a theme that omits axis titles is delegating that naming to the headline. Vega-Lite only for now; where no headline is given, the compiler puts the axis titles back. |
+| `subtitle` | The deck — what is measured, of whom, when, in what units. |
| `encodings` | Channel → encoding map |
| `baseSize` | **Target** layout size in pixels (default 400×320): the size the chart aims for with typical data. Dense data may stretch past it, up to the ceiling. |
| `canvasSize` | **Hard ceiling:** the maximum size the chart may ever reach, including faceted grids. If omitted, the ceiling is `baseSize × options.maxStretch` (default 1.5×). Per-dimension caps are `βx = canvasSize.width / baseSize.width`, `βy = canvasSize.height / baseSize.height` (each ≥ 1). The base is clamped to the ceiling, so a `canvasSize` on its own acts as a fixed box the chart fills and shrinks to fit without overflowing. |
@@ -150,7 +171,7 @@ Maps column name → semantic type. This drives encoding type, formatting, aggre
> **base vs. canvas, in one line:** `baseSize` is what the chart *aims for*;
> `canvasSize` is what it *may never exceed*. Use `canvasSize` for a fixed slot,
> and `baseSize` for a comfortable target that may grow for dense data. See the
-> [Example: Auto Layout](/documentation/chart-sizing).
+> [Example: Auto Layout](/playgrounds/auto-layout).
---
diff --git a/docs/design-stretch-model.md b/docs/design-stretch-model.md
index f2d4063c..4a631341 100644
--- a/docs/design-stretch-model.md
+++ b/docs/design-stretch-model.md
@@ -2,7 +2,7 @@
Physics-based models for automatically sizing chart axes when data outgrows the available canvas.
-New to Flint sizing? Start with [Example: Auto Layout](/documentation/chart-sizing), then return here for the complete algorithm.
+New to Flint sizing? Start with [Example: Auto Layout](/playgrounds/auto-layout), then return here for the complete algorithm.
**How to read this document:** [§1](#1-layout-mode-classification) classifies banded vs continuous axes and routes to the right model. [§2](#2-discrete-axis-elastic-budget-model)–[§5](#5-area-layout-2d-pressure-model) describe the four geometry-specific models. [§6](#6-unified-summary) collects the shared pressure–stretch pattern, decision tree, and implementation map.
diff --git a/docs/figs/chartwall.png b/docs/figs/chartwall.png
index be27d98d..73c0e402 100644
Binary files a/docs/figs/chartwall.png and b/docs/figs/chartwall.png differ
diff --git a/docs/figs/flint-theme-economist.png b/docs/figs/flint-theme-economist.png
new file mode 100644
index 00000000..679700cb
Binary files /dev/null and b/docs/figs/flint-theme-economist.png differ
diff --git a/docs/figs/flint-theme-pop.png b/docs/figs/flint-theme-pop.png
new file mode 100644
index 00000000..9def6ae1
Binary files /dev/null and b/docs/figs/flint-theme-pop.png differ
diff --git a/docs/figs/flint-theme-swiss.png b/docs/figs/flint-theme-swiss.png
new file mode 100644
index 00000000..2eb1267b
Binary files /dev/null and b/docs/figs/flint-theme-swiss.png differ
diff --git a/docs/figs/theme-spec-expl.png b/docs/figs/theme-spec-expl.png
new file mode 100644
index 00000000..8241efb2
Binary files /dev/null and b/docs/figs/theme-spec-expl.png differ
diff --git a/docs/overview.md b/docs/overview.md
index b3ac6713..622139df 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -123,7 +123,7 @@ Full input schema: [API reference](/documentation/api-reference).
| **Code generator** | Phase 2 — `template.instantiate()` | `vegalite/`, `echarts/`, `chartjs/` |
1. **Frontend** — derives encoding type, format, aggregation, scale, domain, color, and sort from dataSpec + data
-2. **Optimizer** — chooses axis span, band step, facet grid, and aspect ratio with physics-based sizing; start with [Example: Auto Layout](/documentation/chart-sizing), then use [Auto Layout Algorithm](/documentation/layout-model) for the equations
+2. **Optimizer** — chooses axis span, band step, facet grid, and aspect ratio with physics-based sizing; start with [Example: Auto Layout](/playgrounds/auto-layout), then use [Auto Layout Algorithm](/documentation/layout-model) for the equations
3. **Code generator** — uses dynamic templates for each `chartType` to emit library-native specs
Pipeline detail: [Architecture](/documentation/architecture).
diff --git a/docs/reference-chartjs.md b/docs/reference-chartjs.md
index 4fece66b..20a01d99 100644
--- a/docs/reference-chartjs.md
+++ b/docs/reference-chartjs.md
@@ -6,7 +6,7 @@ The Chart.js backend is the lightweight embedding target for common chart famili
## What this page covers
-This reference lists the 21 chart types currently supported by the Chart.js backend, grouped into 5 categories. Each chart entry shows:
+This reference lists the 22 chart types currently supported by the Chart.js backend, grouped into 5 categories. Each chart entry shows:
- **Encoding channels** — the visual roles accepted in `chart_spec.encodings`, such as `x`, `y`, `color`, `size`, `column`, or `row`.
- **Options** — template-specific `chart_spec.chartProperties` keys, including control type, domain, default, availability, and description.
@@ -85,6 +85,14 @@ _No template-specific parameters._
_No template-specific parameters._
+###  Lollipop Chart
+
+**Encoding channels:** `x`, `y`, `color`, `column`, `row`
+
+| Parameter | Control | Domain | Default | Availability | Description |
+|---|---|---|---|---|---|
+| `dotSize` | number | 20 – 300 (step 10) | `80` | always | Size of the dot mark. |
+
###  Combo Chart
**Encoding channels:** `x`, `y`, `column`, `row`
diff --git a/docs/reference-echarts.md b/docs/reference-echarts.md
index 4ee28465..cd730d55 100644
--- a/docs/reference-echarts.md
+++ b/docs/reference-echarts.md
@@ -65,6 +65,7 @@ _No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
| `whiskerMethod` | choice | `iqr` (Tukey (1.5 × IQR)), `minmax` (Min–Max) | `iqr` | always | Whiskers |
+| `showPoints` | toggle | on / off | `false` | conditional | Overlay point markers on the line. |
| `showOutliers` | toggle | on / off | `true` | conditional | Outliers |
| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge |
diff --git a/docs/reference-plotly.md b/docs/reference-plotly.md
index 02fc7478..7c408078 100644
--- a/docs/reference-plotly.md
+++ b/docs/reference-plotly.md
@@ -104,7 +104,7 @@ _No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
| `totals` | choice | `auto` (Auto), `none` (None), `first` (First only), `last` (Last only), `both` (First and last) | `auto` | always | Totals |
-| `showTextLabels` | toggle | on / off | `false` | always | Render value labels on the marks. |
+| `showTextLabels` | toggle | on / off | `false` | always | Render value labels on the marks (legacy spelling of showValueLabels). |
###  Pyramid Chart
@@ -128,7 +128,8 @@ _No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
-| `showOutliers` | toggle | on / off | `true` | always | Outliers |
+| `showPoints` | toggle | on / off | `false` | conditional | Overlay point markers on the line. |
+| `showOutliers` | toggle | on / off | `true` | conditional | Outliers |
###  Violin Plot
diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md
index c463217c..c7c6f7f7 100644
--- a/docs/reference-vegalite.md
+++ b/docs/reference-vegalite.md
@@ -106,6 +106,7 @@ The **Availability** column shows whether a parameter is `always` available or `
| `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. |
| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. |
+| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. |
###  Grouped Bar Chart
@@ -115,6 +116,7 @@ The **Availability** column shows whether a parameter is `always` available or `
|---|---|---|---|---|---|
| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge |
| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
+| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. |
###  Stacked Bar Chart
@@ -124,6 +126,7 @@ The **Availability** column shows whether a parameter is `always` available or `
|---|---|---|---|---|---|
| `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. |
+| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. |
###  Lollipop Chart
@@ -135,6 +138,7 @@ The **Availability** column shows whether a parameter is `always` available or `
| `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. |
| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. |
+| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. |
###  Waterfall Chart
@@ -144,7 +148,7 @@ The **Availability** column shows whether a parameter is `always` available or `
|---|---|---|---|---|---|
| `cornerRadius` | number | 0 – 8 (step 1) | `0` | always | Corner radius for supported marks. |
| `totals` | choice | `auto` (Auto), `none` (None), `first` (First), `last` (Last), `both` (Both) | `auto` | conditional | Totals |
-| `showTextLabels` | toggle | on / off | `false` | always | Render value labels on the marks. |
+| `showValueLabels` | toggle | on / off | `false` | always | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. |
| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
###  Gantt Chart
@@ -210,6 +214,10 @@ The **Availability** column shows whether a parameter is `always` available or `
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
| `bandwidth` | number | 0.05 – 2 (step 0.05) | `0` | always | Kernel-density bandwidth (0 = auto). |
+| `showPoints` | toggle | on / off | `false` | always | Overlay point markers on the line. |
+| `showMedian` | toggle | on / off | `false` | always | Median rule |
+| `showContour` | toggle | on / off | `false` | always | Contour |
+| `medianWidth` | number | 0.2 – 1 (step 0.05) | `0.6` | conditional | Median width |
| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
###  Boxplot
@@ -219,6 +227,7 @@ The **Availability** column shows whether a parameter is `always` available or `
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
| `whiskerMethod` | choice | `iqr` (Tukey (1.5 × IQR)), `minmax` (Min–Max) | `iqr` | always | Whiskers |
+| `showPoints` | toggle | on / off | `false` | conditional | Overlay point markers on the line. |
| `showOutliers` | toggle | on / off | `true` | conditional | Outliers |
| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge |
| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
@@ -231,7 +240,9 @@ The **Availability** column shows whether a parameter is `always` available or `
**Encoding channels:** `x`, `y`, `color`
-_No template-specific parameters._
+| Parameter | Control | Domain | Default | Availability | Description |
+|---|---|---|---|---|---|
+| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. |
###  Candlestick Chart
@@ -283,6 +294,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. |
| `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. |
@@ -295,6 +307,8 @@ _No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
+| `showText` | toggle | on / off | `false` | always | Values |
+| `showSeriesInLabel` | toggle | on / off | `false` | conditional | Name in label |
| `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. |
@@ -344,6 +358,7 @@ _No template-specific parameters._
| `innerRadius` | number | 0 – 100 (step 5) | `0` | always | Inner radius as a percentage of the outer radius. |
| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices |
| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
+| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. |
###  Donut Chart
@@ -351,9 +366,10 @@ _No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
-| `innerRadius` | number | 0 – 100 (step 5) | `0` | always | Inner radius as a percentage of the outer radius. |
+| `innerRadius` | number | 0 – 100 (step 5) | `50` | always | Inner radius as a percentage of the outer radius. |
| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices |
| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
+| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. |
###  Rose Chart
@@ -365,6 +381,7 @@ _No template-specific parameters._
| `alignment` | choice | `left` (Left (default)), `center` (Center) | — | always | Segment alignment for radial charts. |
| `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices |
| `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. |
+| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. |
###  Radar Chart
@@ -389,7 +406,7 @@ _No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
-| `showTextLabels` | toggle | on / off | `false` | always | Render value labels on the marks. |
+| `showValueLabels` | toggle | on / off | `false` | always | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. |
| `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. |
| `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. |
diff --git a/docs/test_plan.md b/docs/test_plan.md
index 8c795dc1..122b00b2 100644
--- a/docs/test_plan.md
+++ b/docs/test_plan.md
@@ -2,12 +2,33 @@
## Overview
-Test data lives in `test-data/` as fixture generators (not executable test suites).
-Each file exports generator functions that produce `TestCase[]` arrays. The gallery
-UI (`ChartGallery.tsx`) uses `TEST_GENERATORS` and `GALLERY_SECTIONS` from
-`test-data/index.ts` to render all tests interactively.
-
-**20 test-data files**, **~11,100 lines**, **53 named test generators**.
+Visual test data lives in `packages/flint-js/src/test-data/` as fixture generators
+(not executable test suites). Each case module exports generator functions that
+produce `TestCase[]` arrays. The master `TEST_GENERATORS` registry in
+`packages/flint-js/src/test-data/index.ts` exposes those cases to the gallery,
+editor examples, documentation figures, and the dev playground.
+
+The current registry contains **31 case modules**, **126 generator groups**, and
+**931 generated cases**. Open the site at `/playground/full-test-cases` to inspect
+the complete reference set interactively. Each section renders lazily and selects
+the first backend that supports its chart type.
+
+### Backend bring-up workflow
+
+When adding a backend or porting a chart template:
+
+1. Add or reuse cases in `packages/flint-js/src/test-data/` that cover the chart's
+ normal shape, semantic variants, density/cardinality limits, and edge cases.
+2. Export the generator and register it in `TEST_GENERATORS` in `test-data/index.ts`.
+3. Run `npm run site`, then inspect `/playground/full-test-cases` for broad visual
+ coverage and `/gallery` for curated product-facing examples.
+4. Add focused executable assertions under `packages/flint-js/tests/` for artifact
+ shape and backend-specific behavior.
+5. Run `npm run typecheck` and `npm run test` from the repository root.
+
+The playground is a visual regression surface, not a replacement for executable
+tests. A backend is ready only when both the shared cases render correctly and its
+focused test suite passes.
### Test categories
diff --git a/docs/theme-spec.md b/docs/theme-spec.md
new file mode 100644
index 00000000..fe0fd614
--- /dev/null
+++ b/docs/theme-spec.md
@@ -0,0 +1,119 @@
+# Using themes
+
+A theme in Flint is a formal specification that describes how a chart system behaves throughout creation. It works at three levels:
+
+- **Layout algorithm.** Controls how the compiler allocates space, relates elements, and adapts labels, legends, axes, and annotations.
+- **Semantic roles.** Sets presentation rules by meaning, so field roles, order, grouping, and hierarchy drive contrast, emphasis, and representation.
+- **Geometry and typography.** Defines type, color, surfaces, line weight, corners, and mark shapes to carry a consistent visual identity.
+
+[Explore themes](/themes) applies these three levels to the same set of charts so you can compare their effects directly.
+
+`theme_spec` sits beside `chart_spec` in a `ChartAssemblyInput`. The chart spec says **what the chart means**. The theme spec says **how that meaning should be presented**.
+
+> ThemeSpec currently affects Vega-Lite output. Other backend assemblers ignore it.
+
+## Three ways to use `theme_spec`
+
+Use the tabs below to compare the three accepted forms on a World Bank life-expectancy chart. The snippet abbreviates `data` and `semantic_types`, while keeping `chart_spec` visible for context and `theme_spec` highlighted. The chart still compiles the complete input.
+
+```flint-theme-spec
+theme-spec
+```
+
+### 1. Name a preset
+
+Use a preset ID when one of Flint's built-in design systems fits your product. The shortest form is:
+
+```json
+{
+ "theme_spec": "economist"
+}
+```
+
+Flint currently ships these presets:
+
+```flint-theme-presets
+presets
+```
+
+Preset IDs are stable API values. Use `listThemePresets()` when a product needs to build its own picker.
+
+### 2. Write a custom theme
+
+Pass a JSON object to define a design system of your own:
+
+```json
+{
+ "theme_spec": {
+ "id": "our-brand",
+ "ink": {
+ "series": {
+ "single": "#6b3fa0"
+ }
+ },
+ "layout": {
+ "density": "compact"
+ }
+ }
+}
+```
+
+Every field is optional. Start with the decisions that matter to your product, then add detail as the system grows.
+
+| Block | What it controls |
+| --- | --- |
+| `ink` | Surfaces, text, structural lines, accents, and categorical or numeric color |
+| `type` | Headline, axis, label, annotation, and display-number typography |
+| `structure` | Axes, ticks, grids, baselines, and frames |
+| `marks` | Band width, strokes, corners, outlines, separators, and point sizing |
+| `labels`, `legend`, `dataLabels` | Truncation, placement, visibility, and label ink |
+| `annotation` | Units, axis titles, number formats, point emphasis, and statistics |
+| `layout`, `facets` | Density, title spacing, band steps, panel spacing, and shared scales |
+| `chartDefaults`, `compileDefaults` | House defaults for chart controls, base size, canvas size, and layout limits |
+| `furniture` | Rules, tabs, and other recurring chart chrome |
+| `variants` | Semantic conditions that adapt policy to a chart's role, density, or shape |
+
+Theme rules are semantic. For example, `structure.grid.measure` controls the grid used to read values, whichever physical axis carries the measure. `legend.placement` gives the compiler an ordered set of acceptable positions rather than fixed coordinates. This is what lets one theme generalize across different chart types, data, and canvas sizes.
+
+### 3. Inherit and override
+
+Use `extends` when a preset is close to your brand:
+
+```json
+{
+ "theme_spec": {
+ "extends": "economist",
+ "id": "our-economist",
+ "ink": {
+ "series": {
+ "single": "#6b3fa0"
+ }
+ },
+ "type": {
+ "headline": {
+ "family": "Aptos Display"
+ }
+ }
+ }
+}
+```
+
+Flint starts with the named preset and deep-merges your object over it. Nested objects merge, so changing `ink.series.single` keeps the preset's surfaces, text colors, ramps, and other series rules. Arrays and scalar values replace the preset value in full.
+
+`categorical` and `categoricalExtended` are separate palettes. If your brand replaces categorical color, override both so charts with more series do not fall back to the preset's extended palette.
+
+Use inheritance for a durable brand variation. It keeps the preset's compiler behavior while letting you own the identity that should differ.
+
+## What belongs in a theme
+
+A theme governs presentation and compiler behavior. It may decide:
+
+- how tightly elements are packed;
+- which labels can move outside a mark;
+- whether a legend belongs inline, above, or beside the plot;
+- how semantic groups receive contrast and emphasis;
+- how axes, grids, marks, type, and surfaces are drawn.
+
+A theme does **not** choose fields, aggregation, filtering, or sorting. Those choices determine what the chart means and belong in `data`, `semantic_types`, and `chart_spec`.
+
+Keep that boundary and a theme can travel safely across data, chart types, canvas sizes, and products.
diff --git a/docs/tutorials/exploring-data.md b/docs/tutorials/exploring-data.md
index 7fcccb2d..42b6dfcd 100644
--- a/docs/tutorials/exploring-data.md
+++ b/docs/tutorials/exploring-data.md
@@ -306,7 +306,7 @@ If labels crowd or facets overflow, tune layout via `options` (optional):
```
These control the spring / pressure sizing models — see
-[Example: Auto Layout](/documentation/chart-sizing) for a quick walkthrough or
+[Example: Auto Layout](/playgrounds/auto-layout) for a quick walkthrough or
[Auto Layout Algorithm](/documentation/layout-model) for the full model. Most
tutorials and gallery examples work with defaults.
diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md
index 9e5c0044..e7fdeb2b 100644
--- a/docs/tutorials/getting-started.md
+++ b/docs/tutorials/getting-started.md
@@ -102,6 +102,24 @@ That is the core workflow. The DataSpec says what the data *is*. The ChartSpec
says how you want to *look at it*. Paste the JSON into the [online editor](/editor)
to edit it live.
+### Optional: choose a theme
+
+Add `theme_spec` beside `chart_spec` to apply one of Flint's design systems:
+
+```json
+{
+ "theme_spec": "economist"
+}
+```
+
+A Flint theme is a formal specification that influences layout, semantic
+presentation, and visual identity during compilation. It is more than a color
+or font preset. ThemeSpec currently applies to Vega-Lite output.
+
+See [Using themes](/documentation/theme-spec) to browse the presets, create a
+theme, or inherit one and override selected rules. [Explore themes](/themes)
+shows the same charts under every preset.
+
## Compile it
In JavaScript or TypeScript, pass the same input to an assembler:
@@ -164,6 +182,8 @@ Python support will use the same input shape and is planned for a later release.
- [Example: a data story](/documentation/data-story) shows why the split matters:
one DataSpec becomes five different charts by changing only the ChartSpec.
+- [Using themes](/documentation/theme-spec) explains preset, custom, and
+ inherited ThemeSpecs.
- [Set up Flint MCP](/documentation/setup-flint-mcp) shows how to connect the
MCP server when you want an agent to render charts from chat or an IDE.
- [Agent workflows](/documentation/agent-workflows) shows how to embed Flint's
diff --git a/docs/tutorials/setup-flint-mcp.md b/docs/tutorials/setup-flint-mcp.md
index 16b4a2e0..5585d70b 100644
--- a/docs/tutorials/setup-flint-mcp.md
+++ b/docs/tutorials/setup-flint-mcp.md
@@ -22,19 +22,26 @@ opens that chart locally.
| `render_chart` | Render a static PNG or SVG locally when you need an artifact or the host has no MCP App UI. |
| `compile_chart` | Return backend-native Vega-Lite, ECharts, or Chart.js JSON. |
| `list_chart_types` | Inspect supported chart types and encoding channels. |
+| `list_themes` | Inspect built-in visual themes and retrieve guidance for a selected preset. |
| Resource or prompt | Use it for |
|--------------------|------------|
| `flint://agent-skill` | Load the bundled chart-author instructions. |
+| `flint://theme-skill` | Load the bundled ThemeSpec authoring instructions. |
| `flint://chart-types` | Browse the supported chart catalog. |
| `ui://flint-chart/chart-view.html` | Bundled UI resource used by `create_chart_view` in MCP App hosts. |
| `author_flint_chart` | Start from a prompt that embeds the chart-author skill. |
+| `author_flint_theme` | Start from a prompt that embeds the theme-author skill. |
For best results, have the client load `flint://agent-skill` or run the
`author_flint_chart` prompt before the agent calls the chart tools. The skill
teaches the agent the valid `chartType` names, field-to-channel mappings,
semantic types, data-binding rules, and when to use each rendering tool.
+For a custom visual identity, load `flint://theme-skill` or run
+`author_flint_theme`. The theme-author skill produces a reusable ThemeSpec;
+`list_themes` remains the discovery tool for Flint's built-in presets.
+
## Requirements
You need:
diff --git a/docs/website-design-assets/antv-g6-example-editor-three-column.png b/docs/website-design-assets/antv-g6-example-editor-three-column.png
deleted file mode 100644
index 5aaad95b..00000000
Binary files a/docs/website-design-assets/antv-g6-example-editor-three-column.png and /dev/null differ
diff --git a/docs/website-design-assets/antv-g6-gallery-grid.png b/docs/website-design-assets/antv-g6-gallery-grid.png
deleted file mode 100644
index c94fb71b..00000000
Binary files a/docs/website-design-assets/antv-g6-gallery-grid.png and /dev/null differ
diff --git a/docs/website-design-assets/echarts-example-editor-option-preview.png b/docs/website-design-assets/echarts-example-editor-option-preview.png
deleted file mode 100644
index 2a4d4038..00000000
Binary files a/docs/website-design-assets/echarts-example-editor-option-preview.png and /dev/null differ
diff --git a/docs/website-design-assets/echarts-examples-line-category-gallery.png b/docs/website-design-assets/echarts-examples-line-category-gallery.png
deleted file mode 100644
index 1ab005a1..00000000
Binary files a/docs/website-design-assets/echarts-examples-line-category-gallery.png and /dev/null differ
diff --git a/docs/website-design-assets/observable-home-hero-collage.png b/docs/website-design-assets/observable-home-hero-collage.png
deleted file mode 100644
index 56ff4428..00000000
Binary files a/docs/website-design-assets/observable-home-hero-collage.png and /dev/null differ
diff --git a/docs/website-design-assets/observable-plot-gallery-line-moving-average.png b/docs/website-design-assets/observable-plot-gallery-line-moving-average.png
deleted file mode 100644
index afc49e03..00000000
Binary files a/docs/website-design-assets/observable-plot-gallery-line-moving-average.png and /dev/null differ
diff --git a/docs/website-design-assets/vega-lite-example-gallery-index.png b/docs/website-design-assets/vega-lite-example-gallery-index.png
deleted file mode 100644
index 0960b82e..00000000
Binary files a/docs/website-design-assets/vega-lite-example-gallery-index.png and /dev/null differ
diff --git a/docs/website-design-assets/vega-lite-simple-bar-chart-example.png b/docs/website-design-assets/vega-lite-simple-bar-chart-example.png
deleted file mode 100644
index 29d70dd8..00000000
Binary files a/docs/website-design-assets/vega-lite-simple-bar-chart-example.png and /dev/null differ
diff --git a/docs/website-design-plan.md b/docs/website-design-plan.md
deleted file mode 100644
index 937ca786..00000000
--- a/docs/website-design-plan.md
+++ /dev/null
@@ -1,320 +0,0 @@
-# Flint Chart 官网 / 示例站设计计划(Gallery + Live Editor)
-
-> **状态:** 设计草案(仅规划,未改代码)
-> **日期:** 2026-05-14
-> **范围:** `examples/gallery`、`examples/editor` 的体验与叙事;可选与主文档站或 GitHub Pages 联动
-
----
-
-## 1. 产品目标(读者应在短时间内建立何种认知)
-
-1. **Flint 表达意图更短**:输入为「表格数据 + 字段级 `semantic_types` + 高层 `chart_spec`」,由编译器生成各后端的完整图表配置;用户无需从零编写 Vega-Lite 的 `encoding`、`scale`、`axis`、`legend` 等冗长 JSON。
-2. **默认可读、观感专业**:Gallery 以**大图、清晰栅格、统一留白**为主,避免多枚小图并列削弱第一印象(与 AntV 示例站强调「开箱观感」的方向一致)。
-3. **与「手写 Vega-Lite」对照**:在**同一数据集与相近读图任务**下,用简短文案与(可选)编译产物并排,说明 Flint 如何把版式、格式、色板等决策前移到语义层与推荐逻辑,从而减少 spec 篇幅与决策分支。(Vega-Lite 对部分通道有类型推断与默认样式,但对业务友好的刻度、标签、色盘等,实践中仍常需显式配置;对比时表述应实事求是,见第 9 节。)
-4. **Semantic types 有稳定入口**:用侧栏锚点、折叠长文或子路由之一,讲清 T0 / T1 / T2 分层与降级,并链接至 `design-semantics.md` 供深读。
-
----
-
-## 2. 参考站点调研
-
-下列外链指向各产品官方站点;**截图已放入本仓库** `docs/website-design-assets/`(含 AntV G6、Vega-Lite、Observable、**Apache ECharts**),在 GitHub 或本地 Markdown 预览中应相对于本文路径加载(若 IDE 预览不显示,请确认以仓库根目录打开工作区,并检查 Markdown 预览安全设置)。
-
-### 2.0 AntV G6:图表示例栅格与「示例 + 编辑器」三栏工作台
-
-[G6 图可视化引擎](https://g6.antv.antgroup.com/) 的「图表示例」与单示例页,在信息架构上同时承担 **检索示例** 与 **就地改代码**。
-
-**图 1 — 图表示例首页:左侧分类 + 主区卡片栅格(缩略图 + 标题)**
-
-
-
-| 可借鉴点 | 对应本文章节 |
-|----------|----------------|
-| 侧栏多级分类(特性、场景案例、布局、交互等) | 第 5 节 Gallery、第 7 节导航 |
-| 缩略图与短标题、主区分组标题 | 第 5.3 节(主区内视觉层级) |
-| 顶栏:文档 / API / 示例 / 社区、搜索 | 第 7 节入口 |
-
-**图 2 — 单示例页:左导航、中大预览、右侧代码(JavaScript / Data 分 Tab)**
-
-
-
-| 可借鉴点 | 对应本文章节 |
-|----------|----------------|
-| 预览占据视觉中心 | 第 4 节 Editor、第 5.3 节 |
-| 代码与 **数据** 分 Tab | `data` 与 `semantic_types` 分区展示的参考 |
-| 复制、运行、展开等工具条 | 第 8 节 P1 之后可增强 |
-
-**相关官方链接**
-
-- [G6 API · 数据](https://g6.antv.antgroup.com/api/data)(数据模型与 API 目录,可作「数据 / 语义」文档信息架构的参考)
-- [G6 官网](https://g6.antv.antgroup.com/)
-- [G2 图表示例(英文)](https://g2.antv.antgroup.com/en/examples)(通用统计图示例矩阵)
-
-> 上列截图为 AntV 官方界面,仅作设计与动线参考。
-
-### 2.2 Vega-Lite:Example Gallery 与单示例页(含官方截图)
-
-[Vega-Lite](https://vega.github.io/vega-lite/) 的文档与 [Example Gallery](https://vega.github.io/vega-lite/examples/) 是 Flint 用户最熟悉、也最适合作为 **「手写 spec」对照物** 的参照;[Vega Editor](https://vega.github.io/editor/) 提供在线编辑与分享。
-
-**图 3 — Example Gallery 索引区:以分级列表为主的目录页**
-
-
-
-| 观察 | 对 Flint 的启示 |
-|------|------------------|
-| 类目完整、便于检索(Single-View、Layered、Facet、Interactive 等) | 保留「按场景 / generator 分组」的清晰度 |
-| 首屏以文字目录为主,缩略图密度低于 AntV G6 图表示例首页 | Flint Gallery 若以 **缩略图 + 短标题** 为主,更易形成「一眼看到图」的差异 |
-| 顶栏含 Documentation、Examples、Try Online 等 | 全局导航与「一键试玩」入口值得对齐 |
-
-**图 4 — 单示例页(Simple Bar Chart):图 + 说明 + 在线编辑器链接 + JSON 规格**
-
-
-
-| 观察 | 对 Flint 的启示 |
-|------|------------------|
-| 「View this example in the online editor」类链接 | 对应 Flint 第 5.1 / 5.4 节与第 7 节「Gallery → Editor」深链 |
-| 即使简单柱状图,spec 仍包含 `$schema`、`data`、`mark`、`encoding` 等完整结构 | 对应第 4 节:在 Editor 中并排展示 **Flint 输入** 与 **编译得到的 Vega-Lite**,用篇幅对比体现「少写」 |
-| 默认视觉偏文档演示风格 | Flint 若以默认观感与版式为卖点,需在 Gallery 用真实数据与统一主题证明 |
-
-**相关官方链接**
-
-- [Vega-Lite Example Gallery](https://vega.github.io/vega-lite/examples/)
-- [Vega Editor](https://vega.github.io/editor/)
-- [Vega-Lite 文档首页](https://vega.github.io/vega-lite/docs/)
-
-**设计取舍(文字摘要)**
-
-- Gallery 可沿用「按图表类型与复合视图能力分块」的思路,但首屏需有一句 **Flint 定位**,避免被误读为普通测试列表。
-- Editor 侧可学习 Vega Editor:**错误信息固定区域**、**示例切换不改变整体框架**。
-- 后续可在 `website-design-assets/` 增补「同一用例:Flint 输入 vs 生成 VL」的自制对比图,与上列官方截图并列说明。
-
-### 2.3 Observable:首页叙事与 Observable Plot 示例(含官方截图)
-
-[Observable](https://observablehq.com/) 以响应式 Notebook 为核心;[Observable Plot](https://observablehq.com/plot/) 提供声明式 `Plot.plot({...})` API;示例合集以 Notebook 形式发布,例如官方 [Plot Gallery](https://observablehq.com/@observablehq/plot-gallery),将「结果图 + 简短代码」紧挨展示,与 Flint 希望的「先看到图、再看到多短能写出来」一致。Flint 仍以 **JSON 装配输入 + 多后端编译** 为主,不复制 Notebook 运行时;以下仅借鉴 **版式、首屏叙事与示例节奏**。
-
-**图 5 — Observable Plot Gallery 单例:「Line with moving average」(图在上、代码在下)**
-
-
-
-| 观察 | 对 Flint 的启示 |
-|------|------------------|
-| 面包屑 `OBSERVABLE PLOT > GALLERY`、标题与一段数据来源说明 | Gallery 用例页:**类目路径 + 一句话场景/数据来源**,再进入图与配置 |
-| 图下方紧跟短代码(`marks`、`Plot.windowY` 等),左侧有运行/展开类控件 | Editor:**预览与输入同屏**;Flint 若用 JSON,仍可学习「图与配置垂直相邻、减少视线跳跃」 |
-| 声明式 API、色板与参考线在少量行内表达 | 与第 1、4 节一致:强调 **意图层压缩**;Flint 用 `semantic_types` + `chart_spec` 而非手写 Plot 或 VL 细节 |
-
-**图 6 — Observable 官网首屏:主文案 + CTA + 图与代码拼贴背景**
-
-
-
-| 观察 | 对 Flint 的启示 |
-|------|------------------|
-| 强主标题与副文案,双按钮(试用 / 读文档) | GitHub Pages 或落地页:**一句定位 + Gallery / Editor / 文档** 主次按钮 |
-| 背景拼贴多枚高质量缩略图 | Gallery 或首页:**多图氛围**与单卡大图主预览可结合(注意性能与首屏加载) |
-| 中央卡片内「图 + 代码」一体化 | 与图 5 同理:强化「Flint 输入很短、图很清晰」的一体展示(不必采用 Observable 的深色品牌) |
-
-**相关官方链接**
-
-- [Observable 平台](https://observablehq.com/)
-- [Observable Plot](https://observablehq.com/plot/)
-- [Plot Gallery(Observable Notebook)](https://observablehq.com/@observablehq/plot-gallery)
-
-**设计取舍(文字摘要)**
-
-- **可学**:渐进式教程动线、首屏 CTA、示例页「先图后码」的信息顺序。
-- **不必照搬**:完整 Notebook 工作区、Fork/星标社区流、运行时单元格依赖;Flint MVP 保持 **静态示例站 + 本地/单页 Editor** 即可。
-
-### 2.4 Apache ECharts:Examples 画廊与在线编辑器(含官方截图)
-
-[Apache ECharts](https://echarts.apache.org/en/index.html) 的 [Examples(英文索引)](https://echarts.apache.org/examples/en/index.html) 与单例在线编辑器,是 Flint **ECharts 后端**用户已熟悉的「配置项 `option` + 即时预览」范式;与 Vega-Lite 的声明式 spec 不同,ECharts 更偏 **命令式配置对象**,同样适合在 Flint Editor 中作为 **「编译输出对照」** 或「多引擎 Tab」心智参考(本仓库 `assembleECharts` 已存在)。
-
-**图 7 — Examples:侧栏图表族(含图标)+ 主区缩略图栅格(以 Line 类目为例)**
-
-
-
-| 观察 | 对 Flint 的启示 |
-|------|------------------|
-| 侧栏按图表类型分组并配小图标,当前类目高亮 | Gallery:**一眼可扫的分类 + 当前位置**;可与 generator / 场景树结合 |
-| 主区多列缩略图 + 短标题,同类目下变体丰富 | 第 5.3 节:主区内 **图优先** 的卡片预览;侧栏「按类型」见第 7 节 |
-| 主内容区提供 **Dark mode** 等主题切换 | Flint Gallery / Editor 可提供浅色 / 深色预览,验证默认可读性与导出一致性 |
-
-**图 8 — 单示例在线编辑器:左侧 `option` 代码、右侧实时渲染**
-
-
-
-| 观察 | 对 Flint 的启示 |
-|------|------------------|
-| 经典 **左码右图** 分栏,与 Gallery 点进示例后的工作台一致 | 与第 4、5.4、7 节「Gallery → Editor」及并排对照布局一致,降低学习成本 |
-| Tab:Edit Code / Full Code / Option Preview;语言 JS / TS;**Run** 显式触发刷新 | 可学习:**错误与运行反馈**、全量配置与「最小片段」切换;Flint 若以自动编译为主,仍可保留「手动刷新 / 防抖」选项 |
-| 基础折线亦需配置 `xAxis`、`yAxis`、`series` 等 | Editor 中展示 **Flint 输入 vs 生成的 ECharts option** 时,可并列说明「意图层」如何展开为轴与系列 |
-| 预览区附带下载、截图、分享、渲染耗时等 | 第 8 节 P1 之后可选增强,利于演示与 issue 复现 |
-
-**相关官方链接**
-
-- [Apache ECharts · Examples(英文索引)](https://echarts.apache.org/examples/en/index.html)
-- [Apache ECharts 官网](https://echarts.apache.org/en/index.html)
-- [Handbook(入门与概念)](https://echarts.apache.org/handbook/en)
-- [Option Manual(配置项手册)](https://echarts.apache.org/en/option.html)
-
-**设计取舍(文字摘要)**
-
-- **可学**:图类型侧栏 + 缩略图矩阵、在线编辑器分栏、主题切换与导出类工具。
-- **边界**:ECharts 的 `option` 体量与 VL 不同维度的「冗长」;Flint 叙事应对 **各后端编译产物** 分别诚实展示,避免只对比 VL 而忽略 ECharts 用户的体感。
-
-### 2.5 行业常见模式(摘录)
-
-- 示例页提供「在 Playground / Editor 中打开」。
-- 深链或查询参数传递示例标识,便于分享与从 Gallery 跳入(注意 URL 长度,见第 9 节)。
-- **响应式**:Gallery 可单列堆叠即可;Editor 以桌面宽屏为主,MVP 可明确写清设备优先级。
-
----
-
-## 3. 与当前仓库实现的关系
-
-- **Gallery**:已有左侧 `TEST_GENERATORS` 键列表、`TripleChart` 三后端并排、`tests.slice(0, 6)` 限制展示条数;源码注释标明仍为 scaffold。**规划变更(见第 7 节)**:左侧改为 **按图表类型** 的侧栏(类 ECharts),主区 **仍保留** 当前「用例标题 + 描述 + `TripleChart`」卡片形态;需在数据层维护 **图表类型 ↔ generator / 用例** 的映射(可从 `chart_spec.chartType`、generator 名规则或单独配置表推导,实现时选定唯一来源)。
-- **Editor**:左侧为 `ChartAssemblyInput` 的 JSON(CodeMirror),右侧为后端 Tab(Vega-Lite / ECharts / Chart.js)及可选编译 spec;内置示例见 `examples/editor/src/examples.ts`。
-- **语义设计文档**:`docs/design-semantics.md` 描述 T0 / T1 / T2 与降级策略,适合作为站外或 `/docs` 的权威说明;示例站内页以摘要与链接为主,避免重复维护长文。
-
----
-
-## 4. Live Editor:并排展示「Flint 输入」与「生成的 Vega-Lite」
-
-### 4.1 布局建议(控制复杂度)
-
-在现有「左编辑 / 右预览」基础上,将 **左侧** 拆为 **上下两块**(较左右分栏更不易挤压行宽):
-
-| 区域 | 内容 |
-|------|------|
-| **上:Flint 输入** | 可编辑 JSON:`data`、`semantic_types`、`chart_spec`(与当前行为一致) |
-| **下:对照** | **只读**、格式化的 **由当前输入编译得到的 Vega-Lite spec**;标题建议写作「Vega-Lite 输出(由 Flint 生成)」,避免被误解为用户手写的最简 spec |
-
-用户心智:**只维护上一份意图描述**;下方为同一意图在当前编译器下的 VL 展开结果,用于感受篇幅与结构复杂度。
-
-**第二阶段可选**
-
-- 低调展示行数或嵌套层级对比。
-- 「复制 Vega-Lite」按钮,便于在 Vega Editor 中交叉验证。
-
-### 4.2 错误与空状态
-
-- JSON 语法错误:继续在输入区附近展示解析错误(与现状一致)。
-- 编译失败:保留 Flint 输入可编辑;对照区展示失败原因,避免整页空白。
-
----
-
-## 5. Gallery
-
-### 5.1 布局约束
-
-- **全局**:全站 **顶部导航栏**(类 Vega-Lite:Gallery、Tutorials、Documentation、Usage / Getting started、Ecosystem、GitHub、Try / Editor 等,实现时可按 MVP 裁剪条目)。
-- **Gallery 页内**:**左侧** 为 **按图表类型** 分类的侧栏(类 ECharts:类目 + 可选小图标、当前选中高亮);**右侧主区** **保持与当前 `examples/gallery` 相同** 的用例展示——即每个用例仍以 **标题、描述、`TripleChart`(或等价多后端预览)** 为主的纵向卡片流,**不**把主区改成 ECharts 官网那种纯缩略图矩阵(避免与「和现在 gallery 一样」冲突)。
-- **逐例进入 Editor**:在每个用例 **标题同一行末尾** 或 **标题下第一行**,放置与 Vega-Lite 单示例页同款的文案链:**「View this example in the online editor」**(中文站可并列或单独提供「在在线编辑器中打开」)。点击后 **进入当前 Editor 应用界面**(同仓库 `examples/editor`:开发时为另一 Vite 端口或子路径 `/editor`,上线时为统一域名下的 Editor 路由),并载入该用例对应的 `ChartAssemblyInput`(载荷方式见 §5.4)。
-
-### 5.2 视觉层级(主区内)
-
-- **主预览**:默认突出单一后端(例如 Vega-Lite)或 Flint 首推导出效果;ECharts、Chart.js 作为 Tab 或次级折叠,避免三列长期同权导致「测试页」观感。
-- **多引擎**:以「另存为其他引擎」或「多引擎」Tab 呈现,强调能力边界而非首屏噪音。
-
----
-
-## 6. Semantic types:放置位置与内容边界
-
-### 6.1 放置方案比较
-
-| 方案 | 优点 | 缺点 |
-|------|------|------|
-| **A.** Gallery 页侧栏 **图表类型区下方** 或主区顶部固定「Semantic types」入口 + 页内长折叠 | 曝光高;与第 7 节侧栏并存时需控制高度(可折叠「高级 / 语义」区) | 侧栏信息密度上升 |
-| **B.** 独立路由(如 `/semantic-types`) | 内容易扩展 | 多一页导航与构建配置;需放入顶栏 **Documentation** 子链或主导航 |
-| **C.** 仅链至 `docs/design-semantics.md` | 维护单点 | 离开示例站语境 |
-
-**MVP 建议:** **A**(与图表类型侧栏分区排版);内容膨胀后再引入 **B**,并在顶栏 **Documentation** 中链入。
-
-### 6.2 示例站页内大纲(精简)
-
-1. 一句话:字段语义类型如何影响格式、聚合、基线、色板类别等默认决策。
-2. T0 / T1 / T2:由粗到细,**缺失细粒度类型时降级而非失败**。
-3. 一至两个静态对照(如仅标为数值与标注为 `Amount` / `Proportion` 时的轴与标签差异)。
-4. 指向 `design-semantics.md` 全文。
-
----
-
-## 7. 信息架构:顶栏(类 Vega-Lite)+ Gallery(侧栏类 ECharts、主区保持现状)
-
-### 7.1 全站顶部导航栏
-
-对齐 [Vega-Lite 文档站](https://vega.github.io/vega-lite/) 顶栏信息结构(不必逐项同名,但类目宜接近用户心智):
-
-| 导航项 | 用途(建议) |
-|--------|----------------|
-| **Gallery**(或 **Examples**) | 进入示例画廊(本页为站内核心流量入口之一)。 |
-| **Tutorials** | 分步入门、常见数据集走通;MVP 可单页或外链。 |
-| **Documentation** | API / 装配输入 schema / 链至仓库 `docs/` 与 `design-semantics.md`。 |
-| **Usage**(或 **Getting started**) | 安装、一行代码渲染、与框架集成要点。 |
-| **Ecosystem** | 相关工具、后端矩阵、Roadmap;可精简为单页。 |
-| **GitHub** | 源码仓库。 |
-| **Try online** / **Editor** | **直达** 在线 **Editor** 根路由(空白或默认模板);与 Gallery 内 **「View this example in the online editor」**(带用例上下文)区分。 |
-
-可选:**搜索**、**中 / 英文**(非 MVP 可后做)。
-
-### 7.2 Gallery 页:侧栏 + 主区
-
-```
-Gallery 路由(/ 或 /gallery)
-├── 顶栏(见 §7.1,全站一致)
-├── 左侧边栏
-│ ├── 按「图表类型」分组(参考 ECharts Examples:Line、Bar、Scatter…;类目映射见第 3 节)
-│ └── [可选] Semantic types 入口(见第 6 节)
-└── 主内容区(与当前实现一致)
- └── 当前选中类型下的用例列表:标题 + 描述 + TripleChart(或等价)
- └── 每例标题旁:「View this example in the online editor」→ Editor + 该例载荷
-```
-
-- **侧栏**:负责 **「按图表类型找例」**;可保留图标、选中态、与 ECharts 类似的纵向类目列表。
-- **主区**:**不改为**缩略图-only 的矩阵;延续 **大卡片 + 多后端预览**,以满足「图表展示还是和现在 gallery 一样」的产品要求。
-- **逐例链接**:文案 **「View this example in the online editor」** 与 Vega-Lite 单例页一致,便于用户迁移习惯;点击跳转 **现有 Editor 界面**(见 §5.4)。
-
-### 7.3 Editor 路由(建议)
-
-```
-/editor(或独立子应用 + 统一域名)
-├── Flint 输入(JSON)
-├── 生成的 Vega-Lite(只读对照,见第 4 节)
-└── 预览(主后端 + 多引擎 Tab)
-```
-
-- 从 **顶栏 Try / Editor** 进入:无 `generator` / `case` 参数时加载默认空模板或内置第一个示例。
-- 从 **Gallery 逐例链接** 进入:携带 §5.4 约定参数,自动填充该用例 `ChartAssemblyInput`。
-
-### 7.4 落地页与仓库入口
-
-- 对外首页或 GitHub Pages:**一句定位 + 顶栏或首屏 CTA** 指向 **Gallery** 与 **Editor**;与 Observable 式双按钮可并存,但 **全局顶栏** 为信息架构主轴。
-
----
-
-## 8. 分阶段交付
-
-| 阶段 | 交付内容 | 验收要点 |
-|------|----------|----------|
-| **P0** | Editor 左栏:Flint 输入 + 只读生成 VL;Gallery 顶部对比折叠带 | 新用户能在约半分钟内理解「维护单份输入」 |
-| **P1** | 全站 **顶栏**(Gallery / Tutorials / Documentation / …);Gallery **按图表类型侧栏**;主区保持现有卡片 + `TripleChart`;每例标题处 **「View this example in the online editor」** 深链至 Editor 且可复现该例 | 任意展示用例一键进入 Editor 且状态一致 |
-| **P2** | Gallery 主图 + 多引擎 Tab;精选用例的补充说明 | 整体更接近产品站而非内部测试列表 |
-| **P3** | Semantic types 独立短页或小册式滚动区 + 图示 | 清楚区分「统计类型 Q/N/O/T」与 Flint「业务语义类型」的角色 |
-
----
-
-## 9. 风险与表述原则
-
-- **载荷与隐私**:大 JSON 避免直接塞进 URL;可用 `sessionStorage` 或短键服务端交换(若未来有后端)。
-- **对比诚实性**:对照区展示 **当前版本编译器真实输出**;不虚构「手写 VL 最少行数」。Vega-Lite 具备强表达力与部分默认行为,叙事重点放在 **意图层压缩与默认决策外置**,而非贬低 VL。
-- **维护成本**:卡片文案以模板与数据驱动为主,少量手写「标杆用例」即可。
-
----
-
-## 10. 实现阶段可执行项(备忘)
-
-1. `examples/editor`:基于已有 `assembleVegaLite` 结果序列化至只读面板;解析 URL 查询参数或 `sessionStorage` token,**预填**来自 Gallery 的 `ChartAssemblyInput`。
-2. `examples/gallery`:增加 **全站顶栏** 组件;左侧 **图表类型** 侧栏(数据源:类型 ↔ generator/用例映射);主区沿用现有卡片结构;每例标题行渲染 **「View this example in the online editor」** 并指向 Editor(开发环境需处理跨端口 URL 或反向代理为同域)。
-3. `examples/gallery` 与 `examples/editor`:抽取共享的注册表与「打开 Editor」载荷编码(可考虑 `examples/shared` 等小模块)。
-4. 样式:提取有限 CSS 变量(背景、边框、主色),与 `docs/landing.html` 等品牌触点可选对齐。
-
----
-
-**文档性质:** 设计与调研归档;实施顺序以第 8 节为准,**本文不替代** `design-semantics.md` 中的语义类型技术定义。
diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md
index 6f9d3320..97f83fef 100644
--- a/docs/zh-CN/api-reference.md
+++ b/docs/zh-CN/api-reference.md
@@ -141,7 +141,7 @@ interface ChartAssemblyInput {
| `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)。
+> **base 与 canvas,一句话:** `baseSize` 是图表*瞄准*的尺寸;`canvasSize` 是*绝不超过*的尺寸。固定插槽用 `canvasSize`,舒适目标且密集数据可增长用 `baseSize`。见[示例:自动布局](/playgrounds/auto-layout)。
---
diff --git a/docs/zh-CN/design-stretch-model.md b/docs/zh-CN/design-stretch-model.md
index 9d406594..78218072 100644
--- a/docs/zh-CN/design-stretch-model.md
+++ b/docs/zh-CN/design-stretch-model.md
@@ -2,7 +2,7 @@
当数据超出可用画布时,用于自动调整图表坐标轴尺寸的基于物理的模型。
-初次接触 Flint 尺寸?请从[示例:自动布局](/documentation/chart-sizing)开始,然后回到此处阅读完整算法。
+初次接触 Flint 尺寸?请从[示例:自动布局](/playgrounds/auto-layout)开始,然后回到此处阅读完整算法。
**如何阅读本文档:** [§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 模式、决策树和实现映射。
diff --git a/docs/zh-CN/overview.md b/docs/zh-CN/overview.md
index 93ddb497..6313158e 100644
--- a/docs/zh-CN/overview.md
+++ b/docs/zh-CN/overview.md
@@ -123,7 +123,7 @@ data + semantic_types + chart_spec → assemble*() → 后端配置
| **代码生成器** | Phase 2 — `template.instantiate()` | `vegalite/`、`echarts/`、`chartjs/` |
1. **前端** — 从 dataSpec + data 推导编码类型、格式、聚合、比例尺、域、颜色和排序
-2. **优化器** — 用基于物理的尺寸选择坐标轴跨度、带宽步长、分面网格和宽高比;先从[示例:自动布局](/documentation/chart-sizing)入手,再用[自动布局算法](/documentation/layout-model)了解公式
+2. **优化器** — 用基于物理的尺寸选择坐标轴跨度、带宽步长、分面网格和宽高比;先从[示例:自动布局](/playgrounds/auto-layout)入手,再用[自动布局算法](/documentation/layout-model)了解公式
3. **代码生成器** — 根据 `chartType` 选择模板,生成对应后端的配置
流水线详情见[架构](/documentation/architecture)。
diff --git a/docs/zh-CN/reference-plotly.md b/docs/zh-CN/reference-plotly.md
index ecf7032c..93c104b2 100644
--- a/docs/zh-CN/reference-plotly.md
+++ b/docs/zh-CN/reference-plotly.md
@@ -100,7 +100,7 @@ _无模板专用参数。_
| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 |
|---|---|---|---|---|---|
| `totals` | choice | `auto` (Auto), `none` (None), `first` (First only), `last` (Last only), `both` (First and last) | `auto` | always | 瀑布图总计标记。 |
-| `showTextLabels` | toggle | on / off | `false` | always | 在标记上显示数值标签。 |
+| `showTextLabels` | toggle | on / off | `false` | always | 在标记上显示数值标签(showValueLabels 的旧写法)。 |
###  Pyramid Chart
@@ -124,7 +124,8 @@ _无模板专用参数。_
| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 |
|---|---|---|---|---|---|
-| `showOutliers` | toggle | on / off | `true` | always | 显示离群点。 |
+| `showPoints` | toggle | on / off | `false` | conditional | 在线上叠加点标记。 |
+| `showOutliers` | toggle | on / off | `true` | conditional | 显示离群点。 |
###  Violin Plot
diff --git a/docs/zh-CN/theme-spec.md b/docs/zh-CN/theme-spec.md
new file mode 100644
index 00000000..c20448ad
--- /dev/null
+++ b/docs/zh-CN/theme-spec.md
@@ -0,0 +1,119 @@
+# 使用主题
+
+Flint 主题是一套正式规范,用来描述图表系统在整个创建过程中如何运作。它不是渲染完成后再套上的外观,而是从三个层面生效:
+
+- **布局算法。** 控制编译器如何分配空间、组织元素,以及调整标签、图例、坐标轴和注释。
+- **语义角色。** 根据含义设置表现规则,让字段角色、顺序、分组与层级决定对比、强调和表达方式。
+- **几何与字体。** 定义字体、颜色、表面、线宽、圆角和图形形状,形成统一的视觉识别。
+
+[探索主题](/themes) 会把这三个层面应用到同一组图表上,方便直接比较它们的效果。
+
+`theme_spec` 与 `chart_spec` 并列放在 `ChartAssemblyInput` 中。图表规范说明**图表表达什么**,主题规范说明**这些含义如何呈现**。
+
+> ThemeSpec 目前只影响 Vega-Lite 输出,其他后端的组装器会忽略它。
+
+## `theme_spec` 的三种用法
+
+使用下方标签页,在一张世界银行预期寿命图表上比较三种合法形式。代码片段缩略显示 `data` 和 `semantic_types`,同时保留 `chart_spec` 作为上下文,并高亮 `theme_spec`。图表仍会编译完整输入。
+
+```flint-theme-spec
+theme-spec
+```
+
+### 1. 使用预设
+
+当 Flint 内置的设计系统适合你的产品时,直接使用预设 ID:
+
+```json
+{
+ "theme_spec": "economist"
+}
+```
+
+Flint 目前提供以下预设:
+
+```flint-theme-presets
+presets
+```
+
+预设 ID 是稳定的 API 值。产品需要构建自己的选择器时,可以调用 `listThemePresets()`。
+
+### 2. 创建自定义主题
+
+传入 JSON 对象即可定义自己的设计系统:
+
+```json
+{
+ "theme_spec": {
+ "id": "our-brand",
+ "ink": {
+ "series": {
+ "single": "#6b3fa0"
+ }
+ },
+ "layout": {
+ "density": "compact"
+ }
+ }
+}
+```
+
+所有字段都是可选的。可以先定义产品最重要的决策,再随着系统成长逐步补充。
+
+| 区块 | 控制内容 |
+| --- | --- |
+| `ink` | 表面、文字、结构线、强调色,以及分类或数值颜色 |
+| `type` | 标题、坐标轴、标签、注释与大数字的字体 |
+| `structure` | 坐标轴、刻度、网格、基线与边框 |
+| `marks` | 色带宽度、描边、圆角、轮廓、分隔与点大小 |
+| `labels`, `legend`, `dataLabels` | 截断、位置、显示规则与标签颜色 |
+| `annotation` | 单位、轴标题、数值格式、点强调与统计信息 |
+| `layout`, `facets` | 疏密、标题间距、色带步长、面板间距与共享比例尺 |
+| `chartDefaults`, `compileDefaults` | 图表控件、基础尺寸、画布尺寸与布局限制的默认值 |
+| `furniture` | 分隔线、标签页及其他重复出现的图表结构 |
+| `variants` | 根据语义、密度或图表形态调整规则的条件 |
+
+主题规则由语义驱动。例如,`structure.grid.measure` 控制用于读取数值的网格,无论度量实际位于哪个物理坐标轴。`legend.placement` 向编译器提供按优先级排列的可用位置,而不是固定坐标。因此,同一主题可以适配不同图表类型、数据与画布尺寸。
+
+### 3. 继承并覆盖
+
+当某个预设接近你的品牌时,可以使用 `extends`:
+
+```json
+{
+ "theme_spec": {
+ "extends": "economist",
+ "id": "our-economist",
+ "ink": {
+ "series": {
+ "single": "#6b3fa0"
+ }
+ },
+ "type": {
+ "headline": {
+ "family": "Aptos Display"
+ }
+ }
+ }
+}
+```
+
+Flint 会先读取指定预设,再将你的对象深度合并到其上。嵌套对象会合并,因此修改 `ink.series.single` 时,仍会保留预设中的表面、文字颜色、渐变和其他系列规则。数组和标量则会完整替换预设值。
+
+`categorical` 与 `categoricalExtended` 是两套独立色板。如果品牌需要替换分类颜色,应同时覆盖两者,避免系列较多的图表回退到预设的扩展色板。
+
+继承适合创建长期维护的品牌变体。它保留预设的编译器行为,同时允许你修改需要不同的视觉识别。
+
+## 哪些内容属于主题
+
+主题负责表现方式和编译器行为,例如:
+
+- 元素排列的疏密;
+- 标签何时可以移到图形外;
+- 图例应位于图形内部、上方还是侧面;
+- 语义分组如何获得对比与强调;
+- 坐标轴、网格、图形、字体与表面如何绘制。
+
+主题**不负责**选择字段、聚合、筛选或排序。这些决策决定图表表达什么,应放在 `data`、`semantic_types` 和 `chart_spec` 中。
+
+保持这条边界,主题就能安全地用于不同数据、图表类型、画布尺寸和产品。
diff --git a/docs/zh-CN/tutorials/getting-started.md b/docs/zh-CN/tutorials/getting-started.md
index 65c1b043..ffc7c93e 100644
--- a/docs/zh-CN/tutorials/getting-started.md
+++ b/docs/zh-CN/tutorials/getting-started.md
@@ -87,6 +87,20 @@ Python 包计划在后续版本发布,不包含在首次公开发版中。目
这就是 Flint 的核心:DataSpec 说明数据*是什么*,ChartSpec 说明你想*怎么看*。将 JSON 粘贴到[在线编辑器](/editor)即可实时查看和修改。
+### 可选:选择主题
+
+在 `chart_spec` 旁加入 `theme_spec`,即可使用 Flint 的内置设计系统:
+
+```json
+{
+ "theme_spec": "economist"
+}
+```
+
+Flint 主题是一套正式规范,会在编译过程中影响布局、语义表现和视觉识别,而不只是设置颜色或字体。ThemeSpec 目前只影响 Vega-Lite 输出。
+
+阅读[使用主题](/documentation/theme-spec),了解如何选择预设、创建主题,或继承主题并覆盖部分规则。[探索主题](/themes)会用同一组图表展示所有预设的效果。
+
## 编译
在 JavaScript 或 TypeScript 中,将同一份输入传给编译函数:
@@ -147,6 +161,7 @@ Python 支持将使用相同的输入结构,计划在后续版本发布。
## 接下来读什么
- [示例:数据故事](/documentation/data-story):用同一份 DataSpec 和五种 ChartSpec 生成不同图表。
+- [使用主题](/documentation/theme-spec):了解预设、自定义与继承 ThemeSpec。
- [配置 Flint MCP](/documentation/setup-flint-mcp):在聊天工具或 IDE 中连接 Flint MCP。
- [智能体工作流](/documentation/agent-workflows):将 Flint 集成到自己的智能体产品中。
- [语义类型](/documentation/semantic-types):了解 `YearMonth`、`Quantity`、`Category` 和 `Profit` 等语义标签。
diff --git a/docs/zh-CN/tutorials/setup-flint-mcp.md b/docs/zh-CN/tutorials/setup-flint-mcp.md
index 9ec4eebe..a57cf71b 100644
--- a/docs/zh-CN/tutorials/setup-flint-mcp.md
+++ b/docs/zh-CN/tutorials/setup-flint-mcp.md
@@ -15,16 +15,23 @@
| `render_chart` | 渲染静态 PNG 或 SVG。客户端不支持 MCP Apps,或需要导出图片时使用。 |
| `compile_chart` | 生成可供 Vega-Lite、ECharts 或 Chart.js 直接使用的 JSON 规范。 |
| `list_chart_types` | 列出支持的图表类型和编码通道。 |
+| `list_themes` | 列出内置视觉主题,并获取所选预设的使用指南。 |
| 资源或提示词 | 用途 |
|--------------------|------|
| `flint://agent-skill` | 加载随服务器提供的 Flint 图表编写指南。 |
+| `flint://theme-skill` | 加载随服务器提供的 ThemeSpec 编写指南。 |
| `flint://chart-types` | 浏览支持的图表目录。 |
| `ui://flint-chart/chart-view.html` | `create_chart_view` 使用的 MCP App 界面资源。 |
| `author_flint_chart` | 加载 Flint 图表编写指南的提示词。 |
+| `author_flint_theme` | 加载 Flint 主题编写指南的提示词。 |
调用图表工具前,请让客户端加载 `flint://agent-skill`,或运行 `author_flint_chart` 提示词。编写指南包含有效的 `chartType` 名称、字段与通道的对应关系、语义类型、数据绑定规则,以及各渲染工具的适用场景。
+创建自定义视觉识别时,请加载 `flint://theme-skill`,或运行
+`author_flint_theme`。主题编写技能用于生成可复用的 ThemeSpec;
+`list_themes` 则用于查询 Flint 内置预设及其使用指南。
+
## 要求
你需要:
diff --git a/package-lock.json b/package-lock.json
index 0553d4e3..badbf71a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5916,6 +5916,15 @@
"node": "20 || >=22"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.27.0",
+ "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lucide-react/-/lucide-react-1.27.0.tgz",
+ "integrity": "sha1-d79S6d+sRKuf/RHRuOipbanAzIM=",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/magic-string/-/magic-string-0.30.21.tgz",
@@ -9634,7 +9643,7 @@
},
"packages/flint-js": {
"name": "flint-chart",
- "version": "0.4.1",
+ "version": "0.5.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.14.10",
@@ -9679,7 +9688,7 @@
},
"packages/flint-mcp": {
"name": "flint-chart-mcp",
- "version": "0.4.1",
+ "version": "0.5.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.7.4",
@@ -9688,7 +9697,7 @@
"@resvg/resvg-js": "^2.6.2",
"chart.js": "^4.4.0",
"echarts": "^6.0.0",
- "flint-chart": "^0.4.1",
+ "flint-chart": "^0.5.0",
"vega": "^6.0.0",
"vega-interpreter": "^2.2.1",
"vega-lite": "^6.0.0",
@@ -9777,6 +9786,7 @@
"flint-chart": "*",
"i18next": "^26.3.6",
"katex": "^0.17.0",
+ "lucide-react": "^1.27.0",
"plotly.js-dist-min": "^2.35.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/packages/flint-js/README.md b/packages/flint-js/README.md
index c8ffceab..fd333b07 100644
--- a/packages/flint-js/README.md
+++ b/packages/flint-js/README.md
@@ -42,6 +42,21 @@ const input: ChartAssemblyInput = {
const vegaLiteSpec = assembleVegaLite(input);
```
+Add a formal visual theme without changing the chart's data or encodings:
+
+```ts
+const themedSpec = assembleVegaLite({
+ ...input,
+ theme_spec: 'economist',
+});
+```
+
+Flint ships ten presets and also accepts a custom `ThemeSpec`, or an object
+that `extends` a preset and overrides selected fields. ThemeSpec currently
+affects Vega-Lite output. See
+[Using themes](https://microsoft.github.io/flint-chart/#/documentation/theme-spec)
+and the [live theme wall](https://microsoft.github.io/flint-chart/#/themes).
+
The same `ChartAssemblyInput` compiles to any backend:
```ts
@@ -84,6 +99,7 @@ The Excel backend instead produces a native-chart artifact: use
## Documentation
- [Project overview & docs](https://github.com/microsoft/flint-chart#readme)
+- [Using themes](https://microsoft.github.io/flint-chart/#/documentation/theme-spec)
- [Semantic-type model & rationale](src/docs/design-semantics.md)
- [Stretch / banking layout model](src/docs/design-stretch-model.md)
- [Agent authoring skill](https://github.com/microsoft/flint-chart/blob/main/agent-skills/flint-chart-author/SKILL.md)
diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json
index 9084022b..167841f7 100644
--- a/packages/flint-js/package.json
+++ b/packages/flint-js/package.json
@@ -1,6 +1,6 @@
{
"name": "flint-chart",
- "version": "0.4.1",
+ "version": "0.5.0",
"description": "Semantic-level visualization library that compiles data + semantic types for Vega-Lite, ECharts, Chart.js, Plotly, and Excel.",
"keywords": [
"visualization",
diff --git a/packages/flint-js/src/chartjs/assemble.ts b/packages/flint-js/src/chartjs/assemble.ts
index 53563145..14b12948 100644
--- a/packages/flint-js/src/chartjs/assemble.ts
+++ b/packages/flint-js/src/chartjs/assemble.ts
@@ -62,6 +62,17 @@ import { normalizeChartProperties } from '../core/normalize-properties';
*
* @returns A Chart.js config object with optional `_warnings` and `_width`/`_height` hints
*/
+function applyFieldDisplayNames(config: any, names: Record | undefined): void {
+ if (!names) return;
+ const displayName = (value: unknown) => typeof value === 'string' ? names[value] ?? value : value;
+ for (const scale of Object.values(config.options?.scales ?? {}) as any[]) {
+ if (scale?.title?.text) scale.title.text = displayName(scale.title.text);
+ }
+ for (const dataset of config.data?.datasets ?? []) {
+ if (dataset?.label) dataset.label = displayName(dataset.label);
+ }
+}
+
export function assembleChartjs(input: ChartAssemblyInput): any {
const chartType = input.chart_spec.chartType;
const semanticTypes = input.semantic_types ?? {};
@@ -444,6 +455,8 @@ export function assembleChartjs(input: ChartAssemblyInput): any {
cjsConfig._pivot = legacyPivot.surface;
}
+ applyFieldDisplayNames(cjsConfig, input.field_display_names);
+
return cjsConfig;
}
diff --git a/packages/flint-js/src/chartjs/templates/index.ts b/packages/flint-js/src/chartjs/templates/index.ts
index 0a54c051..d35302c0 100644
--- a/packages/flint-js/src/chartjs/templates/index.ts
+++ b/packages/flint-js/src/chartjs/templates/index.ts
@@ -14,6 +14,7 @@ import { cjsConnectedScatterDef } from './connected-scatter';
import { cjsBubbleChartDef } from './bubble';
import { cjsStripPlotDef } from './jitter';
import { cjsBarChartDef, cjsStackedBarChartDef, cjsGroupedBarChartDef } from './bar';
+import { cjsLollipopChartDef } from './lollipop';
import { cjsComboChartDef } from './combo';
import { cjsLineChartDef } from './line';
import { cjsBumpChartDef } from './bump';
@@ -34,7 +35,7 @@ import { cjsWaterfallChartDef } from './waterfall';
*/
export const cjsTemplateDefs: { [key: string]: ChartTemplateDef[] } = {
'Scatter & Point': [cjsScatterPlotDef, cjsConnectedScatterDef, cjsBubbleChartDef, cjsStripPlotDef],
- 'Bar': [cjsBarChartDef, cjsGroupedBarChartDef, cjsStackedBarChartDef, cjsComboChartDef, cjsHistogramDef, cjsWaterfallChartDef, cjsGanttChartDef],
+ 'Bar': [cjsBarChartDef, cjsGroupedBarChartDef, cjsStackedBarChartDef, cjsLollipopChartDef, cjsComboChartDef, cjsHistogramDef, cjsWaterfallChartDef, cjsGanttChartDef],
'Line & Area': [cjsLineChartDef, cjsBumpChartDef, cjsSlopeChartDef, cjsAreaChartDef, cjsRangeAreaChartDef, cjsEcdfPlotDef],
'Part-to-Whole': [cjsPieChartDef, cjsDoughnutChartDef],
'Polar': [cjsRadarChartDef, cjsRoseChartDef],
diff --git a/packages/flint-js/src/chartjs/templates/lollipop.ts b/packages/flint-js/src/chartjs/templates/lollipop.ts
new file mode 100644
index 00000000..e53dded3
--- /dev/null
+++ b/packages/flint-js/src/chartjs/templates/lollipop.ts
@@ -0,0 +1,141 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * Chart.js Lollipop Chart — thin bar stem from 0 to value + dot at the end
+ * (mirror of echarts/templates/lollipop.ts and vegalite/templates/lollipop.ts).
+ *
+ * Chart.js has no rule mark, so the stem is a `bar` dataset with a fixed
+ * `barThickness`, and the dot is a `line` dataset with `showLine: false` so it
+ * rides the shared category scale (a `scatter` dataset would require numeric
+ * `{x, y}` points instead of category labels).
+ */
+
+import { ChartTemplateDef, ChartPropertyDef } from '../../core/types';
+import {
+ extractCategories,
+ groupBy,
+ buildCategoryAlignedData,
+ getChartJsPalette,
+ getSeriesBorderColor,
+ detectAxes,
+} from './utils';
+import { detectBandedAxisFromSemantics } from '../../core/axis-detection';
+
+/** Stem styling mirrors the ECharts template: black, ~1.5px (the Vega-Lite rule look). */
+const STEM_COLOR = '#000000';
+const STEM_WIDTH_PX = 1.5;
+/** Internal dataset label for the stem; filtered out of legend and tooltip. */
+const STEM_LABEL = '__stem__';
+
+/** Same visual scale as the ECharts template: 6–16px dot diameter. */
+function dotRadiusFromProperty(dotSize: number): number {
+ const diameterPx = Math.max(6, Math.min(10 + (dotSize - 80) / 40, 16));
+ return diameterPx / 2;
+}
+
+export const cjsLollipopChartDef: ChartTemplateDef = {
+ chart: 'Lollipop Chart',
+ template: { mark: 'bar', encoding: {} },
+ channels: ['x', 'y', 'color', 'column', 'row'],
+ markCognitiveChannel: 'length',
+ declareLayoutMode: (cs, table) => {
+ const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' });
+ return {
+ axisFlags: result ? { [result.axis]: { banded: true } } : { x: { banded: true } },
+ resolvedTypes: result?.resolvedTypes,
+ };
+ },
+ instantiate: (spec, ctx) => {
+ const { channelSemantics, table, chartProperties } = ctx;
+ const { categoryAxis, valueAxis } = detectAxes(channelSemantics);
+
+ const catField = channelSemantics[categoryAxis]?.field;
+ const valField = channelSemantics[valueAxis]?.field;
+ if (!catField || !valField || table.length === 0) return;
+
+ const colorField = channelSemantics.color?.field;
+ const categories = extractCategories(
+ table, catField, channelSemantics[categoryAxis]?.ordinalSortOrder,
+ );
+ const stemData = buildCategoryAlignedData(table, catField, valField, categories);
+
+ const isHorizontal = categoryAxis === 'y';
+ const pointRadius = dotRadiusFromProperty(Number(chartProperties?.dotSize ?? 80));
+ const palette = getChartJsPalette(ctx, 'color');
+
+ const datasets: any[] = [{
+ type: 'bar' as const,
+ label: STEM_LABEL,
+ data: stemData,
+ barThickness: STEM_WIDTH_PX,
+ backgroundColor: STEM_COLOR,
+ borderWidth: 0,
+ order: 2,
+ }];
+
+ const dotDataset = (label: string, data: (number | null)[], colorIndex: number) => ({
+ type: 'line' as const,
+ label,
+ data,
+ showLine: false,
+ pointRadius,
+ pointHoverRadius: pointRadius + 2,
+ borderColor: getSeriesBorderColor(palette, colorIndex),
+ backgroundColor: getSeriesBorderColor(palette, colorIndex),
+ pointBorderColor: '#fff',
+ pointBorderWidth: 1,
+ order: 1,
+ });
+
+ if (colorField) {
+ let i = 0;
+ for (const [name, rows] of groupBy(table, colorField)) {
+ datasets.push(dotDataset(
+ name, buildCategoryAlignedData(rows, catField, valField, categories), i,
+ ));
+ i++;
+ }
+ } else {
+ datasets.push(dotDataset(valField, stemData, 0));
+ }
+
+ const zeroDecision = channelSemantics[valueAxis]?.zero;
+ const config: any = {
+ type: 'bar',
+ data: { labels: categories, datasets },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ ...(isHorizontal ? { indexAxis: 'y' as const } : {}),
+ scales: {
+ [categoryAxis]: {
+ title: { display: true, text: catField },
+ },
+ [valueAxis]: {
+ type: 'linear' as const,
+ beginAtZero: zeroDecision ? zeroDecision.zero !== false : true,
+ title: { display: true, text: valField },
+ },
+ },
+ plugins: {
+ legend: {
+ display: !!colorField,
+ labels: { filter: (item: any) => item.text !== STEM_LABEL },
+ },
+ tooltip: {
+ enabled: true,
+ filter: (item: any) => item.dataset?.label !== STEM_LABEL,
+ },
+ },
+ },
+ };
+
+ Object.assign(spec, config);
+ delete spec.mark;
+ delete spec.encoding;
+ },
+ properties: [
+ { key: 'dotSize', label: 'Dot Size', type: 'continuous', min: 20, max: 300, step: 10, defaultValue: 80 },
+ ] as ChartPropertyDef[],
+};
diff --git a/packages/flint-js/src/core/compute-layout.ts b/packages/flint-js/src/core/compute-layout.ts
index b9c6d3fa..30ef36fc 100644
--- a/packages/flint-js/src/core/compute-layout.ts
+++ b/packages/flint-js/src/core/compute-layout.ts
@@ -240,6 +240,19 @@ export function deriveStretchCaps(
// Public API: computeLayout
// ---------------------------------------------------------------------------
+/**
+ * The size a cell in a grid wants to be, before the room has its say. Larger
+ * than a bar's band because a tone needs area to be compared: below about
+ * twenty pixels a patch reads as grout between its neighbours.
+ */
+const CELL_BAND_SIZE = 28;
+
+/**
+ * How much of the wider step squaring a grid may cost. At 1.5 a 30px step will
+ * give way to a 20px square, but not to a 15px one.
+ */
+const SQUARE_CELL_TOLERANCE = 1.5;
+
/**
* Phase 1: Compute layout decisions.
*
@@ -819,7 +832,10 @@ export function computeLayout(
const itemsPerGroup = nominalCount.group;
const defaultGroupStep = itemsPerGroup * maxStepSize;
const minGroupStep = Math.max(Math.ceil(MIN_GROUP_GAP_PX / stepPaddingVal), 2 * itemsPerGroup);
- const groupAxis = computeAxisStep(nominalCount.x, 0, subplotWidth, elasticParamsX);
+ const groupElasticX = options.groupBandFillsLanes
+ ? { ...elasticParamsX, defaultStepSize: elasticParamsX.defaultStepSize * itemsPerGroup }
+ : elasticParamsX;
+ const groupAxis = computeAxisStep(nominalCount.x, 0, subplotWidth, groupElasticX);
const groupStep = Math.max(minGroupStep, Math.min(defaultGroupStep, groupAxis.step));
xStepSize = groupStep;
xStepUnit = 'group';
@@ -835,7 +851,10 @@ export function computeLayout(
const itemsPerGroup = nominalCount.group;
const defaultGroupStep = itemsPerGroup * maxStepSize;
const minGroupStep = Math.max(Math.ceil(MIN_GROUP_GAP_PX / stepPaddingVal), 2 * itemsPerGroup);
- const groupAxis = computeAxisStep(nominalCount.y, 0, subplotHeight, elasticParamsY);
+ const groupElasticY = options.groupBandFillsLanes
+ ? { ...elasticParamsY, defaultStepSize: elasticParamsY.defaultStepSize * itemsPerGroup }
+ : elasticParamsY;
+ const groupAxis = computeAxisStep(nominalCount.y, 0, subplotHeight, groupElasticY);
const groupStep = Math.max(minGroupStep, Math.min(defaultGroupStep, groupAxis.step));
yStepSize = groupStep;
yStepUnit = 'group';
@@ -899,6 +918,76 @@ export function computeLayout(
else subplotHeight = Math.round(stepSize * (count + 1));
}
+ // --- Square cells ---
+ // Two banded axes means the marks are cells, not bars. A bar states its
+ // value as a length along one axis, so its thickness is free; a cell states
+ // its value as a tone, and the eye compares tones by area. A grid of
+ // squares reads as a surface; a grid of thin rectangles reads as stripes,
+ // and invites a comparison along the long side that the data does not
+ // support.
+ //
+ // So the two steps are pulled to one size. The caps used here are the ones
+ // the stretch budget already produced, which is what lets a grid spend a
+ // little extra canvas to come out square. Where the categories are too many
+ // for that — where squaring would cut the wider step by more than a third —
+ // the grid stays rectangular: a shape nobody asked for is not worth losing
+ // that much room over.
+ //
+ // A connected mark whose two axes are both discrete — a bump chart, ranks
+ // over time — is the exception: it is a line, not a grid of cells, so it is
+ // neither squared nor stretched to fill the height. The mark declares a
+ // cross-section per axis; the larger one is the run the line travels along
+ // (time), the smaller the stack it crosses (rank). Squaring the two, or
+ // letting the rank axis grow one band per competitor, only makes the panel
+ // taller and every crossing a near-vertical plunge — the shape the eye
+ // reads worst. Instead the rank axis is held to its thin cross-section and
+ // the run axis is stretched to a bounded multiple of it, so the panel comes
+ // out landscape and the slopes sit nearer 45°. Horizontal room is what
+ // untangles a crossing mass of lines, so the run is where the budget is
+ // spent.
+ const isConnectedMark = typeof continuousMarkCrossSection === 'object'
+ && !!continuousMarkCrossSection.seriesCountAxis;
+ const bothDiscreteConnected = isConnectedMark
+ && xTotalNominalCount > 0 && yTotalNominalCount > 0
+ && !xHasGrouping && !yHasGrouping;
+ if (bothDiscreteConnected && typeof continuousMarkCrossSection === 'object') {
+ const csX = continuousMarkCrossSection.x ?? 0;
+ const csY = continuousMarkCrossSection.y ?? 0;
+ if (csX > 0 && csY > 0) {
+ // Cap the run band's advantage over the cross band: past about 2:1
+ // the extra width buys little and the panel just runs off the edge.
+ const RUN_AR_CAP = 2;
+ const runIsX = csX >= csY;
+ const crossCS = runIsX ? csY : csX;
+ const runBudget = runIsX
+ ? Math.floor(maxSubplotW / xTotalNominalCount)
+ : Math.floor(maxSubplotH / yTotalNominalCount);
+ const cross = Math.max(minStepVal,
+ Math.min(runIsX ? yStepSize : xStepSize, crossCS));
+ const ratio = Math.min(RUN_AR_CAP, Math.max(1, Math.max(csX, csY) / Math.min(csX, csY)));
+ const run = Math.max(
+ runIsX ? xStepSize : yStepSize,
+ Math.min(runBudget, Math.round(cross * ratio)));
+ if (runIsX) { xStepSize = run; yStepSize = cross; }
+ else { yStepSize = run; xStepSize = cross; }
+ }
+ }
+ if (xTotalNominalCount > 0 && yTotalNominalCount > 0 && !xHasGrouping && !yHasGrouping
+ && !bothDiscreteConnected) {
+ const capX = Math.floor(maxSubplotW / xTotalNominalCount);
+ const capY = Math.floor(maxSubplotH / yTotalNominalCount);
+ const generous = Math.round(CELL_BAND_SIZE * Math.max(1, sizeRatio));
+ // The square is the narrower of the two steps — that one already fits —
+ // grown to the generous size if there is room for it on both axes.
+ const wanted = Math.max(generous, Math.min(xStepSize, yStepSize));
+ const square = Math.min(capX, capY, wanted);
+ const widest = Math.max(xStepSize, yStepSize);
+ if (square >= minStepVal && square * SQUARE_CELL_TOLERANCE >= widest) {
+ xStepSize = square;
+ yStepSize = square;
+ }
+ }
+
// --- Nominal discrete subplot sizing ---
// For nominal discrete axes, one backend (VL) overrides subplotWidth
// with step-based sizing (width:{step:N}), so the subplot dimension
diff --git a/packages/flint-js/src/core/decisions.ts b/packages/flint-js/src/core/decisions.ts
index 0b23dc04..a6b9e6c4 100644
--- a/packages/flint-js/src/core/decisions.ts
+++ b/packages/flint-js/src/core/decisions.ts
@@ -78,12 +78,28 @@ function validateTemporalParsing(
fieldName: string,
fromRegistry: boolean,
): boolean {
- const sampleValues = data.map(r => r[fieldName]).slice(0, 15).filter((v: any) => v != null);
+ // Sample distinct values, not rows. Cartesian data is commonly ordered
+ // outer-axis first: in a 60 × 40 heatmap the first StartDate repeats for
+ // 40 rows while EndDate changes immediately. Sampling rows therefore
+ // declared one date field ordinal and the other temporal solely because of
+ // loop order. Walk until we have enough distinct evidence instead.
+ const sampleValues: any[] = [];
+ const seen = new Set();
+ for (const row of data) {
+ const value = row[fieldName];
+ if (value == null) continue;
+ const key = value instanceof Date
+ ? `date:${value.getTime()}`
+ : `${typeof value}:${String(value)}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ sampleValues.push(value);
+ if (sampleValues.length >= 15) break;
+ }
if (sampleValues.length === 0) return false;
// Single unique value → not useful as temporal axis (would show a single point)
- const uniqueValues = new Set(sampleValues.map(String));
- if (uniqueValues.size <= 1) return false;
+ if (sampleValues.length <= 1) return false;
const looksTemporalValue = (val: any): boolean => {
if (val instanceof Date) return true;
diff --git a/packages/flint-js/src/core/field-semantics.ts b/packages/flint-js/src/core/field-semantics.ts
index 63a041a4..91e8b76c 100644
--- a/packages/flint-js/src/core/field-semantics.ts
+++ b/packages/flint-js/src/core/field-semantics.ts
@@ -61,6 +61,26 @@ export interface SemanticAnnotation {
/** Unit or currency code. E.g., "USD", "°C", "kg" */
unit?: string;
+ /**
+ * The value a diverging colour scale should pivot on — what the reader is
+ * being asked to compare against.
+ *
+ * This is a judgement, not a fact about the field, which is why it has to
+ * be declared rather than inferred. A temperature in °C pivots at 0 if the
+ * question is whether it freezes, and at something nearer 18 if the
+ * question is whether a city is comfortable to live in; the numbers are
+ * identical either way and only the question tells them apart. Declare it
+ * when the chart is about the comparison — above and below an average, a
+ * target, a baseline period, a comfort line — and leave it out otherwise,
+ * in which case the pivot falls back to whatever the type and the data
+ * make obvious: a sign change at zero, a bounded domain's centre, or no
+ * pivot at all.
+ *
+ * Declaring one also *asserts* the split: the scale diverges even if every
+ * reading happens to land on one side of it.
+ */
+ divergingMidpoint?: number;
+
/** Explicit ordinal ordering. E.g., ["Low", "Medium", "High"] */
sortOrder?: string[];
}
@@ -112,7 +132,7 @@ export interface DivergingInfo {
/** Whether this type is always diverging or only when data spans both sides */
inherent: boolean;
/** Source of the midpoint determination */
- source: 'unit' | 'type-intrinsic' | 'domain' | 'data';
+ source: 'annotation' | 'unit' | 'type-intrinsic' | 'domain' | 'data';
}
/**
@@ -211,7 +231,7 @@ export function normalizeAnnotation(
// =============================================================================
/** Map currency codes to display symbols */
-const CURRENCY_MAP: Record = {
+export const CURRENCY_MAP: Record = {
USD: '$', EUR: '€', GBP: '£', JPY: '¥', CNY: '¥',
KRW: '₩', INR: '₹', BRL: 'R$', CAD: 'CA$', AUD: 'A$',
CHF: 'CHF', SEK: 'kr', NOK: 'kr', DKK: 'kr',
@@ -854,6 +874,7 @@ export function resolveNice(
* Resolve diverging midpoint information for a field.
*
* Priority chain:
+ * 0. annotation.divergingMidpoint — the author said what to compare against
* 1. annotation.unit → type lookup (°C → 0, °F → 32)
* 2. type-intrinsic midpoint (Sentiment → 0, Correlation → 0)
* 3. annotation.intrinsicDomain midpoint (Rating [1,5] → 3)
@@ -869,6 +890,13 @@ export function resolveDivergingInfo(
const entry = getRegistryEntry(semanticType);
// Types with diverging='none' don't get diverging treatment
+ // 0. Declared. Nothing below this line can know what the chart is asking,
+ // so a stated pivot outranks every inferred one — and it holds even when
+ // the data sits entirely on one side, because the comparison is the point.
+ if (annotation.divergingMidpoint !== undefined) {
+ return { midpoint: annotation.divergingMidpoint, inherent: true, source: 'annotation' };
+ }
+
// 1. Unit-derived (Temperature)
if (semanticType === 'Temperature' && annotation.unit) {
const unitMidpoints: Record = {
diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts
index 14524256..46c35f27 100644
--- a/packages/flint-js/src/core/index.ts
+++ b/packages/flint-js/src/core/index.ts
@@ -194,3 +194,18 @@ export {
resolveStackable,
resolveSortDirection,
} from './field-semantics';
+
+// ThemeSpec: public visual-system vocabulary and chart-specific grounding
+export {
+ type ThemeSpec,
+ type ThemePreset,
+ type DesignDecisions,
+ type ThemeReport,
+ type Presence,
+ type GroundingContext,
+ groundTheme,
+ THEME_PRESETS,
+ DEFAULT_THEME_ICON,
+ listThemePresets,
+ resolveThemeSpec,
+} from './theme';
diff --git a/packages/flint-js/src/core/semantic-types.ts b/packages/flint-js/src/core/semantic-types.ts
index 1039972f..10aa9591 100644
--- a/packages/flint-js/src/core/semantic-types.ts
+++ b/packages/flint-js/src/core/semantic-types.ts
@@ -642,6 +642,7 @@ const colorSchemes = {
// Diverging - good for data with meaningful center point
diverging: {
redBlue: 'redblue',
+ blueOrange: 'blueorange',
redGrey: 'redgrey',
redYellowBlue: 'redyellowblue',
redYellowGreen: 'redyellowgreen',
@@ -652,6 +653,32 @@ const colorSchemes = {
},
};
+/**
+ * Which end of a diverging scale gets the warm arm.
+ *
+ * Every diverging scheme Vega names runs from its *first*-named colour at the
+ * domain minimum to its last at the maximum: `redblue` is red at the bottom
+ * and blue at the top, `blueorange` the other way round. So choosing a scheme
+ * is choosing a polarity, and the polarity belongs to the field, not to the
+ * palette. Only two orders are intrinsic, and they point opposite ways:
+ *
+ * - **Intensity.** Warm means *more*. Nobody reads blue as hotter, denser or
+ * faster. Temperature is the plain case, but so is any measure whose two
+ * ends are simply less and more of one thing, pivoted at a reference we
+ * picked. Warm belongs at the top.
+ * - **Valence.** Red means *loss*. Money below zero, a shrinking share, a
+ * poor score, a negative sentiment. Here the sign is the reading, and red
+ * is the side the reader is meant to wince at. Warm belongs at the bottom.
+ *
+ * Nothing else is intrinsic. Two named sides — a political lean, agree against
+ * disagree — carry their colour in the categories, not in the scale. And a
+ * field with no valence at all still leaves the reader holding "warmer is
+ * more", which is why intensity, not valence, is what we assume when the type
+ * tells us nothing.
+ */
+const DIVERGING_WARM_HIGH = 'blueorange';
+const DIVERGING_WARM_LOW = 'redblue';
+
/**
* Get recommended color scheme based on semantic type and encoding context.
*
@@ -707,7 +734,7 @@ export function getRecommendedColorScheme(
// Temperature
if (semanticType === 'Temperature') {
if (colorHint?.type === 'diverging') {
- return { scheme: 'redblue', type: 'diverging', reason: 'temperature diverging around freezing point' };
+ return { scheme: DIVERGING_WARM_HIGH, type: 'diverging', reason: 'temperature diverging around freezing point, warm end high' };
}
return { scheme: 'reds', type: 'sequential', reason: 'temperature single-direction uses sequential' };
}
@@ -715,7 +742,7 @@ export function getRecommendedColorScheme(
// Percentage
if (semanticType === 'Percentage') {
if (colorHint?.type === 'diverging') {
- return { scheme: 'redblue', type: 'diverging', reason: 'percentage spans positive and negative' };
+ return { scheme: DIVERGING_WARM_LOW, type: 'diverging', reason: 'percentage spans positive and negative, red is the losing side' };
}
return { scheme: 'oranges', type: 'sequential', reason: 'percentage all same sign uses sequential' };
}
@@ -723,7 +750,7 @@ export function getRecommendedColorScheme(
// Price/Amount
if (['Price', 'Amount'].includes(semanticType)) {
if (colorHint?.type === 'diverging') {
- return { scheme: 'redblue', type: 'diverging', reason: 'financial data spans positive and negative' };
+ return { scheme: DIVERGING_WARM_LOW, type: 'diverging', reason: 'financial data spans positive and negative, red is the losing side' };
}
return { scheme: 'goldgreen', type: 'sequential', reason: 'financial data uses gold-green' };
}
@@ -731,7 +758,7 @@ export function getRecommendedColorScheme(
// Score - evaluation metrics; diverging when hint says so (e.g., domain midpoint)
if (semanticType === 'Score') {
if (colorHint?.type === 'diverging') {
- return { scheme: 'redblue', type: 'diverging', reason: 'score/rating diverging around midpoint' };
+ return { scheme: DIVERGING_WARM_LOW, type: 'diverging', reason: 'score diverging around midpoint, red is the poor side' };
}
return { scheme: 'yelloworangebrown', type: 'sequential', reason: 'scores use warm sequential' };
}
@@ -754,7 +781,12 @@ export function getRecommendedColorScheme(
// Geographic locations - use geographic-friendly palettes
if (getRegistryEntry(semanticType ?? '').t1 === 'GeoPlace') {
if (uniqueValueCount <= 10) {
- return { scheme: 'set2', type: 'categorical', reason: 'geographic regions use distinct pastels' };
+ // Not a pastel set. A pastel is chosen for filled area — a
+ // choropleth, a stacked band — where there is enough of it to read
+ // a faint hue. A place just as often arrives as a one-pixel line
+ // or as the colour of its own label, and set2's yellow on white is
+ // then a line the reader has to hunt for.
+ return { scheme: 'tableau10', type: 'categorical', reason: 'places are named categories, at a contrast that survives thin marks' };
}
return { scheme: 'tableau20', type: 'categorical', reason: 'many regions use large categorical' };
}
@@ -775,10 +807,10 @@ export function getRecommendedColorScheme(
// Names (persons, companies, products) - use saturated schemes for readability
if (semanticType === 'Name') {
- return {
- scheme: uniqueValueCount > 8 ? 'tableau20' : 'set2',
- type: 'categorical',
- reason: 'names use readable categorical'
+ return {
+ scheme: uniqueValueCount > 8 ? 'tableau20' : 'tableau10',
+ type: 'categorical',
+ reason: 'names use readable categorical'
};
}
@@ -792,7 +824,15 @@ export function getRecommendedColorScheme(
// PercentageChange) pass through here and should honor their diverging hint.
if (measureTypes.has(semanticType)) {
if (colorHint?.type === 'diverging') {
- return { scheme: 'redblue', type: 'diverging', reason: 'measure with diverging nature' };
+ // A signed measure splits into gain and loss, so red goes to the
+ // bottom. Everything else that happens to straddle a pivot — a
+ // bare Quantity, a Count, a Distance — splits into less and more,
+ // and there red at the bottom would tell the reader that small is
+ // bad when all we meant was small.
+ const signed = getRegistryEntry(semanticType).t1 === 'SignedMeasure';
+ return signed
+ ? { scheme: DIVERGING_WARM_LOW, type: 'diverging', reason: 'signed measure, red is the negative side' }
+ : { scheme: DIVERGING_WARM_HIGH, type: 'diverging', reason: 'measure with no valence, warm end high' };
}
const sequentialSchemes = ['viridis', 'blues', 'greens', 'reds', 'yelloworangebrown', 'goldgreen'];
return {
diff --git a/packages/flint-js/src/core/theme/ground.ts b/packages/flint-js/src/core/theme/ground.ts
new file mode 100644
index 00000000..fa52f419
--- /dev/null
+++ b/packages/flint-js/src/core/theme/ground.ts
@@ -0,0 +1,1898 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * Level 2 — grounding.
+ *
+ * Takes a portable ThemeSpec and the signals the compiler already resolved for
+ * *this* chart, and returns `DesignDecisions`: every role bound to a concrete
+ * part of the chart, every policy resolved against the space actually
+ * available, and still not a single backend property name.
+ *
+ * Grounding is allowed to downgrade. It is not allowed to do so silently.
+ */
+
+import type {
+ DesignDecisions,
+ LegendPlacement,
+ NumericGuard,
+ Presence,
+ Ramp,
+ ResolvedAxis,
+ ResolvedRule,
+ ResolvedSeriesInk,
+ ResolvedText,
+ SizeToken,
+ ThemeGuard,
+ ThemeReport,
+ ThemeSpec,
+ TypeRole,
+} from './types.js';
+import {
+ contrastingInk,
+ isDarkSurface,
+ isPaintedSurface,
+ luminance,
+ mixHex,
+ parseColor,
+ presenceWidth,
+ resolvePresenceInk,
+ sampleRamp,
+} from './presence.js';
+import { CURRENCY_MAP } from '../field-semantics.js';
+import { getRegistryEntry } from '../type-registry.js';
+import { inferValueLabelFormat, longestLabelChars } from './value-label-format.js';
+import { deepMerge } from './merge.js';
+
+// ---------------------------------------------------------------------------
+// Input
+// ---------------------------------------------------------------------------
+
+/**
+ * Everything grounding is allowed to look at. Deliberately a flat record of
+ * compiler *facts* — if grounding needs something that is not here, that is a
+ * signal the compiler does not actually know it, and the ThemeSpec should not
+ * have been allowed to depend on it.
+ */
+export interface GroundingContext {
+ chartType: string;
+ /** Template's `markCognitiveChannel`, widened for `angle`/`text` families. */
+ markChannel: string;
+ /** Mark families present in the instantiated chart, e.g. `['bar','text']`. */
+ markTypes: string[];
+ /** The resolved `showSeriesInLabel`: the chart names its series on the marks. */
+ namesOnMarks?: boolean;
+ /** Per-channel resolved semantics (phase 0). */
+ channelSemantics: Record;
+ /** Encoding types after template-driven conversion (e.g. Q→O for bars). */
+ resolvedTypes?: Record;
+ axisFlags?: { x?: { banded?: boolean }; y?: { banded?: boolean } };
+ /**
+ * What the backend spec actually put on x and y. Templates are free to
+ * name their semantic channels `high`/`low`/`open`/`close`, in which case
+ * `channelSemantics` says nothing about the axes the reader will see. This
+ * is a fact about the chart, not a style choice, so grounding may use it.
+ */
+ positional?: {
+ x?: { type?: string; field?: string };
+ y?: { type?: string; field?: string };
+ /** Whatever colour-like channel a data mark carries, wherever it sits. */
+ color?: { type?: string; field?: string };
+ /** Whether the data marks are stacked into segments. */
+ stacked?: boolean;
+ };
+ layout: {
+ subplotWidth: number;
+ subplotHeight: number;
+ xStep: number;
+ yStep: number;
+ xStepUnit?: 'item' | 'group';
+ yStepUnit?: 'item' | 'group';
+ stepPadding: number;
+ titleFontSize: number;
+ legendFontSize: number;
+ facet?: { columns: number; rows: number };
+ };
+ table: any[];
+ canvasSize: { width: number; height: number };
+ /** True when the template stacks its series (sum or normalize). */
+ stacked?: boolean | 'normalize';
+ /** Set when the chart is a share-of-total by construction (pie, donut). */
+ partToWhole?: boolean;
+ /**
+ * Whether the chart carries a headline.
+ *
+ * A house that omits axis titles is not saying the measure needs no name;
+ * it is saying the name is written above the chart. Where nothing is
+ * written there, the delegation has nowhere to go.
+ */
+ titled?: boolean;
+ /** The surface the host page provides, if the theme defers to it. */
+ hostSurface?: string;
+ /**
+ * The reader's own answer to "print the numbers?", from
+ * `chartProperties.showValueLabels`.
+ *
+ * Absent leaves the house's `dataLabels.show` policy in charge — that
+ * policy is what seeds the control in the first place. A present value is
+ * a decision someone made about *this* chart, so it outranks the standing
+ * preference — but `on` is a preference to print, not a licence to
+ * overprint: it still yields where the marks are too dense to read,
+ * exactly as a house's own `always` does.
+ */
+ valueLabels?: 'on' | 'off';
+}
+
+// ---------------------------------------------------------------------------
+// Design tokens
+// ---------------------------------------------------------------------------
+
+/**
+ * Fold the house's compiler settings under whatever the caller stated.
+ *
+ * Three levels, and the order is not negotiable: a value in the chart spec is
+ * a decision someone made about *this* chart, the theme is a standing
+ * preference, and flint's default is what is left when nobody said anything.
+ *
+ * Returns the merged options; keys the caller left undefined take the house's.
+ */
+export function resolveCompileDefaults>(
+ theme: ThemeSpec | undefined,
+ authored: T | undefined,
+): { options: T; report: ThemeReport[] } {
+ const house = theme?.compileDefaults as Record | undefined;
+ const stated = (authored ?? {}) as Record;
+ if (!house) return { options: stated as T, report: [] };
+ const merged: Record = { ...stated };
+ const report: ThemeReport[] = [];
+ for (const [key, value] of Object.entries(house)) {
+ if (value === undefined) continue;
+ if (stated[key] !== undefined) {
+ report.push({
+ stage: 'ground',
+ path: `compileDefaults.${key}`,
+ message: `the house prefers \`${key}: ${JSON.stringify(value)}\`, but the chart states its own — the chart's stands`,
+ });
+ continue;
+ }
+ merged[key] = value;
+ report.push({
+ stage: 'ground',
+ path: `compileDefaults.${key}`,
+ message: `house preset: \`${key}\` set to ${JSON.stringify(value)}`,
+ });
+ }
+ return { options: merged as T, report };
+}
+
+/**
+ * House rules for a chart type, folded into the chart properties before the
+ * template runs.
+ *
+ * This is the one part of theming that happens *before* the chart is built:
+ * whether a line carries points or a bump chart is smoothed changes the marks
+ * themselves, not their dress, so it cannot be done by restyling afterwards.
+ *
+ * Only keys the caller left unset are filled, and only keys the template
+ * actually declares — a house cannot invent a control, and it does not get to
+ * overrule a reader who has already chosen.
+ */
+export function resolveChartDefaults(
+ theme: ThemeSpec | undefined,
+ chartType: string,
+ declared: { key: string }[] | undefined,
+ authored: Record | undefined,
+ target: Record,
+): ThemeReport[] {
+ const defaults = theme?.chartDefaults;
+ // A slopegraph reads in one of two ways, and which one is a house matter.
+ // An editorial house takes the value axis away — no spine, no ruler — so
+ // the only place a value can be read is off the mark itself: it prints the
+ // number at each end, with the series name beside it, and needs no colour
+ // key. A house that keeps its value axis (a journal panel with a measured
+ // spine) reads the numbers off that axis and tells the lines apart the
+ // ordinary way, with a legend — printing a name on every end point there
+ // would fight the axis for the same margin and clutter a small panel.
+ //
+ // So the end-label treatment is the default only where the house omits the
+ // measure axis line. Houses may still override in their own defaults, a
+ // caller who set the control keeps it, and baseline (no theme) is left
+ // alone — it keeps its legend.
+ const omitsMeasureAxis = theme?.structure?.axis?.measure?.line === 'omit';
+ const globalDefaults: Record> = omitsMeasureAxis
+ ? { 'Slope Chart': { showText: true, showSeriesInLabel: true } }
+ : {};
+ const wanted = {
+ ...(globalDefaults['*'] ?? {}),
+ ...(globalDefaults[chartType] ?? {}),
+ ...(defaults?.['*'] ?? {}),
+ ...(defaults?.[chartType] ?? {}),
+ };
+ if (Object.keys(wanted).length === 0) return [];
+ const keys = new Set((declared ?? []).map((p) => p.key));
+ const report: ThemeReport[] = [];
+ for (const [key, value] of Object.entries(wanted)) {
+ if (!keys.has(key)) {
+ report.push({
+ stage: 'ground',
+ path: `chartDefaults.${chartType}.${key}`,
+ message: `the house asks for \`${key}\`, which \`${chartType}\` does not offer — dropped`,
+ });
+ continue;
+ }
+ if (authored?.[key] !== undefined) {
+ report.push({
+ stage: 'ground',
+ path: `chartDefaults.${chartType}.${key}`,
+ message: `the house prefers \`${key}: ${JSON.stringify(value)}\`, but the chart already states one — the chart's own setting stands`,
+ });
+ continue;
+ }
+ target[key] = value;
+ report.push({
+ stage: 'ground',
+ path: `chartDefaults.${chartType}.${key}`,
+ message: `house rule: \`${key}\` set to ${JSON.stringify(value)}`,
+ });
+ }
+ return report;
+}
+
+const TEXT_TOKENS: Record = {
+ '100': 10, '200': 12, '300': 14, '400': 16, '500': 20, '600': 24,
+ hero700: 28, hero800: 32, hero900: 40, hero1000: 68,
+};
+
+const WEIGHTS: Record = { regular: 400, medium: 500, semibold: 600, bold: 700 };
+
+function tokenToPx(size: SizeToken | undefined): number | undefined {
+ if (size == null) return undefined;
+ if (typeof size === 'number') return size;
+ const m = /^text\.(.+)$/.exec(size);
+ if (m && TEXT_TOKENS[m[1]] != null) return TEXT_TOKENS[m[1]];
+ const n = Number(size);
+ return Number.isFinite(n) ? n : undefined;
+}
+
+// ---------------------------------------------------------------------------
+// Variant resolution
+// ---------------------------------------------------------------------------
+
+function numericGuardHolds(g: NumericGuard, value: number): boolean {
+ if (g.eq != null && value !== g.eq) return false;
+ if (g.lt != null && !(value < g.lt)) return false;
+ if (g.lte != null && !(value <= g.lte)) return false;
+ if (g.gt != null && !(value > g.gt)) return false;
+ if (g.gte != null && !(value >= g.gte)) return false;
+ return true;
+}
+
+interface Signals {
+ markChannel: string;
+ hasBandedAxis: boolean;
+ seriesCount: number;
+ /** False when a series field exists but this stage cannot count it. */
+ seriesCountKnown: boolean;
+ categoryCount: number;
+ isPartToWhole: boolean;
+ isSigned: boolean;
+ isTemporal: boolean;
+ isFaceted: boolean;
+ isSummarised: boolean;
+ canvasWidth: number;
+}
+
+function guardHolds(guard: ThemeGuard, s: Signals): boolean {
+ for (const [key, want] of Object.entries(guard)) {
+ if (want == null) continue;
+ const got = (s as any)[key];
+ if (typeof want === 'object') {
+ if (!numericGuardHolds(want as NumericGuard, Number(got))) return false;
+ } else if (got !== want) {
+ return false;
+ }
+ }
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Signal derivation
+// ---------------------------------------------------------------------------
+
+const SERIES_CHANNELS = ['color', 'group', 'detail', 'series', 'shape', 'stroke'];
+const FACET_CHANNELS = ['column', 'row', 'facet'];
+// Charts whose subject is the shape of a distribution, drawn from an area mark
+// rather than a summary mark. They summarise like a box plot does, so they take
+// the same label escape — a printed value names a quantity they were chosen not
+// to reduce to. (A box plot itself is caught earlier by its `boxplot` mark.)
+const DISTRIBUTION_SHAPE_CHARTS = new Set(['Violin Plot', 'Density Plot']);
+// A table with in-row bars: every value is also printed in its own column, so
+// the bar is a secondary in-cell glyph and the category axis is a row-header
+// gutter, not a base the bars stand on.
+const TABLE_CHARTS = new Set(['Bar Table']);
+// A multi-value glyph carries several measures in one mark — a candlestick is
+// open/high/low/close — so there is no single scalar per datum to print. The
+// mark itself is the value; a lone number stamped on it would name one of four
+// prices and mislead on the other three. These charts look labelable (a measure
+// on a position, a banded axis, not a distribution summary) but must never
+// print a value, and — the reason this matters — must never let a house drop
+// the measure axis on the false premise that the value is printed elsewhere.
+const MULTI_VALUE_GLYPH_CHARTS = new Set(['Candlestick Chart']);
+
+// The title block's vertical rhythm, as a multiple of the headline / deck font
+// size. A house's whitespace personality reaches the title here: `tight` packs
+// the chart up under the headline (a dense figure, a dashboard tile); `loose`
+// gives an action title room to breathe (a slide exhibit). `normal` preserves
+// the ratios the realizer used before the block was expressible.
+const TITLE_GAP: Record<'tight' | 'normal' | 'loose', number> = { tight: 0.45, normal: 0.9, loose: 1.7 };
+const DECK_GAP: Record<'tight' | 'normal' | 'loose', number> = { tight: 0.25, normal: 0.55, loose: 1.05 };
+
+function distinctCount(table: any[], field: string | undefined): number {
+ if (!field) return 0;
+ const seen = new Set();
+ for (const row of table) {
+ const v = row?.[field];
+ if (v !== undefined && v !== null) seen.add(v);
+ }
+ return seen.size;
+}
+
+function channelType(ctx: GroundingContext, channel: string): string | undefined {
+ return ctx.resolvedTypes?.[channel]
+ ?? ctx.channelSemantics?.[channel]?.type
+ ?? (channel === 'x' || channel === 'y' ? ctx.positional?.[channel]?.type : undefined);
+}
+
+/** Does this channel exist on screen at all, whoever named it? */
+function channelPresent(ctx: GroundingContext, channel: 'x' | 'y'): boolean {
+ return Boolean(ctx.channelSemantics?.[channel] ?? ctx.positional?.[channel]);
+}
+
+/**
+ * What a channel actually carries, from whichever stage knows. A layered
+ * template states its colour field on one layer and nothing at the top, so the
+ * semantic layer can be silent about a distinction the reader plainly sees.
+ */
+function channelFact(ctx: GroundingContext, channel: string | undefined): { field?: string; type?: string } | undefined {
+ if (!channel) return undefined;
+ const sem = ctx.channelSemantics?.[channel];
+ if (sem?.field) return sem;
+ const pos = (ctx.positional as any)?.[channel];
+ return pos?.field ? pos : sem;
+}
+
+/**
+ * Parts that sum to a hundred are already stated in per cent, whatever the
+ * field was called. That is a fact about the numbers rather than a guess about
+ * the name, so grounding may use it.
+ */
+function percentOfWhole(ctx: GroundingContext, channel: string): string | undefined {
+ const field = channelFact(ctx, channel)?.field;
+ if (!field) return undefined;
+ let sum = 0;
+ let n = 0;
+ for (const row of ctx.table) {
+ const v = row?.[field];
+ if (typeof v === 'number') { sum += v; n += 1; }
+ }
+ return n >= 3 && Math.abs(sum - 100) < 0.5 ? '%' : undefined;
+}
+
+/**
+ * The unit a measure is counted in, when the chart already knows it.
+ *
+ * Either the annotation says so outright, or the field names it the way a
+ * person does — `CO₂ (ppm)`, `Unemployment (%)`. Anything longer than a short
+ * tag is a phrase, not a unit, and belongs in the subtitle.
+ */
+const UNIT_IN_FIELD_NAME = /\(([^()]{1,6})\)\s*$/;
+
+function unitText(ctx: GroundingContext, channel: string): string | undefined {
+ const sem = ctx.channelSemantics?.[channel];
+ const declared = sem?.semanticAnnotation?.unit;
+ const field = sem?.field ?? (ctx.positional as any)?.[channel]?.field;
+ const named = typeof field === 'string' ? field.match(UNIT_IN_FIELD_NAME) : null;
+ const raw = (typeof declared === 'string' && declared.length > 0 && declared.length <= 6)
+ ? declared
+ : named?.[1];
+ if (!raw) return undefined;
+ // A currency is written with its sign, not its ISO code: `$8`, not `8 USD`.
+ return CURRENCY_MAP[raw.toUpperCase()] ?? raw;
+}
+
+/**
+ * Whether the labels on a channel say what they are without being told.
+ *
+ * `Jan`, `Cairo`, `Chrome`, `2019` name themselves: a reader who sees them
+ * knows at once what kind of thing they are, and a title over them —
+ * `Month`, `City` — only repeats what is already on the page. `26`, `5300`,
+ * `0.42` do not: a number is an instance of nothing until someone says what it
+ * counts, and the title is where that is said.
+ *
+ * The registry already sorts this out. A type the reader meets as a *name* or
+ * a *date* carries its own kind; one they meet as a *quantity* does not, and
+ * neither does a rank or a bin, whose labels are numbers wearing an order.
+ * This is a fact about the field, so grounding may use it — it is exactly what
+ * a house means when it says it wants a title only `whenAmbiguous`.
+ */
+function labelsNameThemselves(ctx: GroundingContext, channel: string | undefined): boolean {
+ if (!channel) return false;
+ const semanticType = ctx.channelSemantics?.[channel]?.semanticAnnotation?.semanticType;
+ if (typeof semanticType !== 'string') {
+ // Nothing said about the field. Fall back to what the chart put on the
+ // channel: names and dates read as themselves, numbers do not.
+ const type = channelType(ctx, channel);
+ return type === 'nominal' || type === 'temporal';
+ }
+ const entry = getRegistryEntry(semanticType);
+ return entry.t1 === 'DateGranule'
+ || entry.visEncodings.includes('nominal')
+ || entry.visEncodings.includes('temporal');
+}
+
+/**
+ * A field that no row carries is not a field with one value — it is a field
+ * this stage cannot count, usually because a backend transform will create it.
+ * Saying "one" would silently collapse a colour scale.
+ */
+function fieldPresent(table: any[], field: string | undefined): boolean {
+ if (!field) return false;
+ return table.some((row) => row != null && Object.prototype.hasOwnProperty.call(row, field));
+}
+
+interface Bindings {
+ measureChannels: Array<'x' | 'y'>;
+ categoricalChannel?: 'x' | 'y';
+ seriesChannel?: string;
+ facetChannel?: string;
+}
+
+function bindRoles(ctx: GroundingContext): Bindings {
+ const measureChannels: Array<'x' | 'y'> = [];
+ let categoricalChannel: 'x' | 'y' | undefined;
+
+ for (const ch of ['x', 'y'] as const) {
+ if (!channelPresent(ctx, ch)) continue;
+ const t = channelType(ctx, ch);
+ const banded = ctx.axisFlags?.[ch]?.banded === true;
+ // A banded axis carries identity even when its field is quantitative
+ // (a binned histogram axis), so banding wins over the encoding type.
+ if (t === 'quantitative' && !banded) measureChannels.push(ch);
+ else categoricalChannel = ch;
+ }
+ // Both quantitative (scatter): there is no categorical axis, and both axes
+ // take the measure role. Both discrete (heatmap): neither does.
+ if (measureChannels.length === 2) categoricalChannel = undefined;
+
+ const seriesChannel = SERIES_CHANNELS.find((c) => ctx.channelSemantics?.[c]?.field)
+ ?? (ctx.positional?.color?.field ? 'color' : undefined);
+ const facetChannel = FACET_CHANNELS.find((c) => ctx.channelSemantics?.[c]?.field);
+ return { measureChannels, categoricalChannel, seriesChannel, facetChannel };
+}
+
+function deriveSignals(ctx: GroundingContext, b: Bindings): Signals {
+ const seriesField = channelFact(ctx, b.seriesChannel)?.field;
+ const catField = channelFact(ctx, b.categoricalChannel)?.field;
+
+ let isSigned = false;
+ // Not only the measures on the axes: a heat map counts in colour, and a
+ // temperature that goes below zero is signed wherever it is drawn.
+ const signedChannels = [...b.measureChannels, ...(b.seriesChannel ? [b.seriesChannel] : [])];
+ for (const ch of signedChannels) {
+ const f = channelFact(ctx, ch)?.field;
+ if (!f) continue;
+ for (const row of ctx.table) {
+ const v = row?.[f];
+ if (typeof v === 'number' && v < 0) { isSigned = true; break; }
+ }
+ if (isSigned) break;
+ }
+
+ const isTemporal = (['x', 'y'] as const).some((ch) => channelType(ctx, ch) === 'temporal');
+
+ const seriesKnown = fieldPresent(ctx.table, seriesField);
+
+ return {
+ markChannel: ctx.markChannel,
+ hasBandedAxis: ctx.axisFlags?.x?.banded === true || ctx.axisFlags?.y?.banded === true,
+ seriesCount: seriesField ? (seriesKnown ? distinctCount(ctx.table, seriesField) : 0) : 1,
+ seriesCountKnown: !seriesField || seriesKnown,
+ categoryCount: catField ? distinctCount(ctx.table, catField) : 0,
+ isPartToWhole: ctx.partToWhole === true
+ || ctx.stacked === 'normalize'
+ || Boolean(ctx.channelSemantics?.theta?.field),
+ isSigned,
+ isTemporal,
+ isFaceted: Boolean(b.facetChannel),
+ isSummarised: ctx.markTypes.some((m) => m === 'boxplot' || m === 'errorbar' || m === 'errorband')
+ // A violin or density plot draws a distribution as a *shape* built
+ // from an area mark — the same escape a box plot gets from its
+ // `boxplot` mark, these earn from what they are, not how they draw.
+ // Their subject is the silhouette; a single number stamped on it
+ // names a quantity the chart was chosen not to reduce to.
+ || DISTRIBUTION_SHAPE_CHARTS.has(ctx.chartType),
+ canvasWidth: Math.round(ctx.layout.subplotWidth || ctx.canvasSize.width),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Grounding
+// ---------------------------------------------------------------------------
+
+export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDecisions {
+ const report: ThemeReport[] = [];
+ const say = (path: string, message: string) => report.push({ stage: 'ground', path, message });
+
+ const bindings = bindRoles(ctx);
+ const signals = deriveSignals(ctx, bindings);
+ // A cell matrix already supplies both positional structures through its
+ // tiles. Axis grids add no location cue and can show through painted cell
+ // gaps, so they stand down while the theme's tile policy separates cells.
+ const gridCells = signals.markChannel === 'color'
+ && ctx.axisFlags?.x?.banded === true
+ && ctx.axisFlags?.y?.banded === true
+ && Boolean(ctx.positional?.x && ctx.positional?.y);
+
+ // --- variants -----------------------------------------------------------
+ let theme: ThemeSpec = themeIn;
+ for (const variant of themeIn.variants ?? []) {
+ if (!variant.when || !guardHolds(variant.when, signals)) continue;
+ theme = deepMerge(theme, variant.then);
+ say('variants', `applied variant ${JSON.stringify(variant.when)}${variant.because ? ` — ${variant.because}` : ''}`);
+ }
+
+ // --- surface ------------------------------------------------------------
+ const houseCanvas = theme.ink?.surface?.canvas;
+ const deferToHost = (theme.ink?.surface?.source ?? 'house') === 'host';
+ const canvas = (deferToHost ? (ctx.hostSurface ?? houseCanvas) : houseCanvas) ?? '#ffffff';
+ const plot = theme.ink?.surface?.plot ?? canvas;
+ const panel = theme.ink?.surface?.panel ?? plot;
+ const dark = isDarkSurface(plot);
+
+ const text = {
+ primary: theme.ink?.text?.primary ?? contrastingInk(plot, '#f3f2f1', '#121212'),
+ secondary: theme.ink?.text?.secondary ?? mixHex(plot, contrastingInk(plot, '#ffffff', '#000000'), 0.72),
+ muted: theme.ink?.text?.muted ?? mixHex(plot, contrastingInk(plot, '#ffffff', '#000000'), 0.45),
+ inverse: theme.ink?.text?.inverse ?? (dark ? '#121212' : '#ffffff'),
+ };
+ const foreground = text.primary;
+
+ // --- typography ---------------------------------------------------------
+ // Grounding resolves size against the space actually available: the same
+ // token is a different number of pixels on a 700px chart and a 250px one.
+ const targetWidth = theme.layout?.targetWidth ?? 300;
+ const available = ctx.layout.subplotWidth || ctx.canvasSize.width || targetWidth;
+ const scale = clamp(Math.pow(available / targetWidth, 0.3), 0.85, 1.2);
+ const minSize = theme.type?.minSize ?? 8;
+ const bodyFamily = theme.type?.axisLabel?.family
+ ?? theme.type?.valueLabel?.family
+ ?? theme.type?.headline?.family;
+
+ function resolveType(role: TypeRole | undefined, fallbackSize: number, fallbackColor: string): ResolvedText {
+ const px = tokenToPx(role?.size) ?? fallbackSize;
+ const sized = Math.max(minSize, Math.round(px * scale * 2) / 2);
+ return {
+ font: role?.family ?? bodyFamily,
+ fontSize: sized,
+ fontWeight: role?.weight ? WEIGHTS[role.weight] : undefined,
+ fontStyle: role?.style === 'italic' ? 'italic' : undefined,
+ color: role?.color ?? fallbackColor,
+ };
+ }
+
+ const axisLabelText = resolveType(theme.type?.axisLabel, 10, text.secondary);
+ const axisTitleText = resolveType(theme.type?.axisTitle, 10, text.secondary);
+ const headline = resolveType(theme.type?.headline, 14, text.primary);
+ const deck = resolveType(theme.type?.deck, 11, text.secondary);
+ const valueLabel = resolveType(theme.type?.valueLabel, 10, text.primary);
+ const keyLabel = resolveType(theme.type?.keyLabel, 10, text.secondary);
+
+ // --- structure ----------------------------------------------------------
+ const structure = theme.structure ?? {};
+ const structureInk = theme.ink?.structure ?? {};
+
+ const ink = (presence: Presence | undefined, roleInk: string | undefined, fallback: Presence) =>
+ resolvePresenceInk({ presence, surface: plot, roleInk, foreground, fallback });
+
+ const rule = (presence: Presence | undefined, roleInk: string | undefined, fallback: Presence, base = 1): ResolvedRule => {
+ const color = ink(presence, roleInk, fallback);
+ return { show: color !== null, color: color ?? 'transparent', width: presenceWidth(presence ?? fallback, base) };
+ };
+
+ const gridStyle = structure.grid?.style ?? 'solid';
+ const gridDash = gridStyle === 'dashed' ? [3, 3] : gridStyle === 'dotted' ? [1, 3] : undefined;
+ const gridWeight = structure.grid?.weight ?? 1;
+
+ const measureGrid: ResolvedRule = {
+ ...rule(structure.grid?.measure, structureInk.grid, 'quiet', gridWeight),
+ dash: gridDash,
+ };
+ const categoryGrid: ResolvedRule = {
+ ...rule(structure.grid?.category, structureInk.grid, 'omit', gridWeight),
+ dash: gridDash,
+ };
+ // Zero is not one gridline among the others: it is where the measure
+ // changes sign, and on a chart of lengths it is the line every mark is
+ // measured from. A house that wants it stated says so; the default is to
+ // let it be an ordinary line.
+ const zeroRule: ResolvedRule | undefined = structure.grid?.zero
+ && structure.grid.zero !== 'omit'
+ ? rule(structure.grid.zero, structureInk.zero ?? structureInk.rule ?? structureInk.axis, 'full')
+ : undefined;
+
+ const frame = rule(structure.frame, structureInk.frame ?? structureInk.axis, 'omit');
+ const baseline = rule(structure.baseline, structureInk.axis, 'full');
+
+ const truncation = theme.labels?.truncation ?? 'ellipsis';
+ const labelFlush = theme.labels?.flush === true;
+ const axisTitlesPolicy = theme.annotation?.axisTitles ?? 'whenAmbiguous';
+
+ // Which axis the chart is read *along*. Where a category sits on an axis
+ // that is the answer; where both axes carry quantities — a connected
+ // scatter, a phase plot — the horizontal one still runs the reading order.
+ const indexChannel: 'x' | 'y' | undefined = bindings.categoricalChannel
+ ?? (bindings.measureChannels.includes('x') && bindings.measureChannels.includes('y') ? 'x' : undefined);
+
+ function buildAxis(channel: 'x' | 'y', role: 'categorical' | 'measure'): ResolvedAxis {
+ const spec = role === 'measure' ? structure.axis?.measure : structure.axis?.categorical;
+ const opposite = spec?.placement === 'opposite';
+ const orient: ResolvedAxis['orient'] = channel === 'x'
+ ? (opposite && channel !== indexChannel ? 'top' : 'bottom')
+ : (opposite ? 'right' : 'left');
+
+ // The axis a reader indexes the chart *by* is not always the discrete
+ // one: a connected scatter has two quantities and still reads left to
+ // right. Houses that draw a rule under the categories draw it under
+ // that axis too — it is the base the chart stands on, not a ruler for
+ // reading values off. So the index axis takes the categorical line
+ // even when what it carries is a number.
+ const indexing = role === 'categorical' || channel === indexChannel;
+ const lineSpec = indexing ? (structure.axis?.categorical ?? spec) : spec;
+
+ // A base rule is the line the marks stand on. Where no axis carries a
+ // measure at all — a grid of cells, whose quantity is in the colour —
+ // there is nothing standing on it, and the rule is just a line under a
+ // list of names.
+ const standsOnIt = bindings.measureChannels.length > 0;
+ // A bar table prints each value in its own column, so its category axis
+ // is a row-header gutter, not a base the bars stand on — like the
+ // no-measure grid above, a rule under the names is a line under a list.
+ const tableGutter = indexing && TABLE_CHARTS.has(ctx.chartType);
+ const domain = (indexing && !standsOnIt) || tableGutter
+ ? rule('omit', structureInk.axis, 'omit', lineSpec?.lineWeight ?? 1)
+ : rule(lineSpec?.line, structureInk.axis, indexing ? 'full' : 'omit', lineSpec?.lineWeight ?? 1);
+ if (((indexing && !standsOnIt) || tableGutter) && (lineSpec?.line ?? 'full') !== 'omit') {
+ say(`structure.axis.categorical.line`, tableGutter
+ ? 'a bar table prints its values in a column — the category axis is a row-header gutter, not a base, so a rule under the names is a line under a list'
+ : 'no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing');
+ }
+ const tickLen = spec?.tickLength === 'long' ? 5 : spec?.tickLength === 'short' ? 2 : 3;
+ // A row-header gutter has neither spine nor ticks — dropping the line
+ // but keeping ticks leaves them floating against nothing.
+ const ticksRule = tableGutter
+ ? rule('omit', structureInk.axis, 'omit')
+ : rule(spec?.ticks, structureInk.axis, 'omit');
+ const inward = spec?.tickDirection === 'inward';
+
+ // A title that only repeats what the labels already say is noise.
+ // `whenAmbiguous` is a question about the field: `Jan Feb Mar` needs
+ // nobody to write `Month` over it, and a column of `26 20 14` names
+ // nothing until someone writes `Temp (°C)` beside it. A rank or a
+ // binned range is in the second group even though it sits on a
+ // categorical axis — its labels are numbers wearing an order.
+ //
+ // A house that drops its axis titles altogether is leaning on the
+ // headline to name the measure, and a headline names one. Where both
+ // rulers carry a measure — one quantity plotted against another — the
+ // headline cannot say which is which, and two rows of bare numbers
+ // name nothing. The titles stay.
+ //
+ // And a chart with no headline at all has nothing to lean on. `omit`
+ // is a delegation, not a deletion: where the words it delegates to were
+ // never written, the labels that name nothing get their title back.
+ const twoMeasures = bindings.measureChannels.includes('x')
+ && bindings.measureChannels.includes('y');
+ const selfNaming = labelsNameThemselves(ctx, channel);
+ const undelegated = !selfNaming && ctx.titled !== true;
+ const showTitle = axisTitlesPolicy === 'always'
+ ? true
+ : axisTitlesPolicy === 'omit'
+ ? ((twoMeasures && role === 'measure') || undelegated)
+ : !selfNaming;
+ if (axisTitlesPolicy === 'whenAmbiguous' && selfNaming !== (role !== 'measure')) {
+ say(`structure.axis.${role}.title`,
+ selfNaming
+ ? `the labels on ${channel} name their own kind — a title over them would repeat what is already read`
+ : `the labels on ${channel} are values, not names — without a title nothing on the axis says what they count`);
+ }
+ if (axisTitlesPolicy === 'omit' && undelegated) {
+ say('annotation.axisTitles',
+ `the house omits axis titles because the headline names the measure — this chart has no headline, so the title on ${channel} stays`);
+ }
+ if (axisTitlesPolicy === 'omit' && twoMeasures && role === 'measure' && channel === 'x') {
+ say('annotation.axisTitles',
+ 'both rulers carry a measure — a headline can name one of them, so the axis titles are kept');
+ }
+
+ // A measure axis is a ruler, and how finely it is graduated is a house
+ // matter: roughly one label every 45px reads as ordinary, one every
+ // 60px as quiet. Three is the floor — two gradations is not a ruler.
+ const density = spec?.tickDensity;
+ const span = channel === 'x' ? ctx.layout.subplotWidth : ctx.layout.subplotHeight;
+ const tickCount = role === 'measure'
+ ? Math.max(3, Math.round((span / (density === 'sparse' ? 60 : density === 'dense' ? 30 : 45))))
+ : undefined;
+
+ // A house that drops axis titles drops the only place the unit was
+ // written. Where it asks for the unit on the ticks instead, the tag is
+ // recovered from what the chart already knows the field to be — but
+ // only where the axis still counts in that unit. A normalized stack
+ // reads in shares, whatever the field was measured in.
+ const unitPolicy = theme.annotation?.unit ?? 'never';
+ const inFieldUnits = ctx.stacked !== 'normalize' && !ctx.partToWhole;
+ const unit = role === 'measure' && inFieldUnits ? unitText(ctx, channel) : undefined;
+ const unitTag = unitPolicy !== 'never' ? unit : undefined;
+
+ // Where the house keeps its axis titles, the title is the natural place
+ // for the unit — `Weight (lb)` — and the ticks stay bare numbers.
+ const titleUnit = showTitle && theme.annotation?.unitsInAxisTitle === true ? unit : undefined;
+
+ // The gap between a label and the plot is the same gap whether or not a
+ // tick is drawn in it. Where there is one, the tick spans the first part
+ // of that distance and the padding covers the rest; where the house
+ // draws none — or turns them inward — the padding is the whole distance,
+ // and 4px leaves the label sitting against the edge of the plot.
+ const defaultLabelGap = labelFlush ? 2 : 4 + tickLen;
+ const ruleToLabelGap = spec?.labelGap ?? defaultLabelGap;
+ const labelPadding = ticksRule.show && !inward
+ ? Math.max(0, ruleToLabelGap - tickLen)
+ : ruleToLabelGap;
+
+ return {
+ role,
+ orient,
+ domain,
+ ticks: {
+ ...ticksRule,
+ size: ticksRule.show ? tickLen : 0,
+ offset: inward ? -tickLen : 0,
+ },
+ grid: gridCells
+ ? rule('omit', structureInk.grid, 'omit', gridWeight)
+ : indexing ? categoryGrid : measureGrid,
+ label: {
+ ...axisLabelText,
+ limit: truncation === 'never' ? 0 : undefined,
+ padding: labelPadding,
+ flush: labelFlush,
+ angle: theme.labels?.angle === 'horizontal' ? 0 : undefined,
+ },
+ title: {
+ show: showTitle,
+ ...axisTitleText,
+ ...(showTitle ? { placement: theme.annotation?.axisTitlePlacement } : {}),
+ ...(titleUnit ? { unit: titleUnit } : {}),
+ },
+ tickCount,
+ tickLabels: (indexing ? structure.axis?.categorical : spec)?.tickLabels,
+ indexing,
+ ...(indexing || !zeroRule ? {} : { zeroRule }),
+ unit: unitTag ? { text: unitTag, where: unitPolicy } : undefined,
+ };
+ }
+
+ const axes: DesignDecisions['axes'] = {};
+ for (const ch of bindings.measureChannels) axes[ch] = buildAxis(ch, 'measure');
+ if (bindings.categoricalChannel) {
+ axes[bindings.categoricalChannel] = buildAxis(bindings.categoricalChannel, 'categorical');
+ }
+ // A heat map has *two* category axes and the bindings can only name one.
+ // The other is just as much a ruler for the reader, and left unbound it
+ // keeps whatever the template drew — its own titles, its own ink.
+ for (const ch of ['x', 'y'] as const) {
+ if (!axes[ch] && ctx.positional?.[ch]) axes[ch] = buildAxis(ch, 'categorical');
+ }
+ if (theme.labels?.angle === 'rotated' && axes.x) {
+ axes.x.label.angle = -45;
+ }
+ // A house may set its category labels flat, but "flat" is a preference and
+ // "legible" is not. Where the band is narrower than the word standing under
+ // it, the angle goes back to the layout pass, which owns fit.
+ //
+ // The reverse is just as true and matters more often: the layout pass sized
+ // the labels in flint's own type, and a house that sets smaller labels buys
+ // room the layout did not know it would have. A name that now fits under
+ // its band should be read straight, not at forty-five degrees because of a
+ // measurement taken in a font the chart no longer uses.
+ if (axes.x && (bindings.categoricalChannel === 'x'
+ || ctx.positional?.x?.type === 'nominal' || ctx.positional?.x?.type === 'ordinal')) {
+ const field = channelFact(ctx, 'x')?.field ?? ctx.positional?.x?.field;
+ const type = ctx.positional?.x?.type ?? channelFact(ctx, 'x')?.type;
+ const banded = type === 'nominal' || type === 'ordinal';
+ const longest = field
+ ? Math.max(0, ...ctx.table.map((r) => String(r?.[field] ?? '').length))
+ : 0;
+ // Mixed-case words average out narrower than the widest glyph — half
+ // the point size a character is close enough — plus a couple of pixels
+ // so two names never touch.
+ const needed = longest * (axes.x.label.fontSize ?? 10) * 0.5 + 2;
+ const step = ctx.layout.xStep;
+ const fits = longest > 0 && step > 0 && needed <= step;
+ if (axes.x.label.angle === 0 && !fits && longest > 0 && step > 0) {
+ axes.x.label.angle = undefined;
+ say('axes.x.label.angle',
+ `the house sets category labels flat, but the widest needs ~${Math.round(needed)}px in a ${Math.round(step)}px band — the angle is left to the layout`);
+ } else if (axes.x.label.angle == null && fits && banded && theme.labels?.angle !== 'rotated') {
+ axes.x.label.angle = 0;
+ say('axes.x.label.angle',
+ `the widest name needs ~${Math.round(needed)}px and the band is ${Math.round(step)}px — at the house's label size they read straight`);
+ }
+ }
+
+ // --- series ink ---------------------------------------------------------
+ const series = groundSeriesInk(theme, ctx, bindings, signals, say);
+
+ // --- legend -------------------------------------------------------------
+ const legendSpec = theme.legend ?? {};
+ const ranked: LegendPlacement[] = legendSpec.placement?.length
+ ? legendSpec.placement
+ : ['right'];
+
+ const seriesField = channelFact(ctx, bindings.seriesChannel)?.field;
+ const catField = channelFact(ctx, bindings.categoricalChannel)?.field;
+ // A legend over a quantitative series is a key to values, not to names.
+ const seriesIsValueKey = channelFact(ctx, bindings.seriesChannel)?.type === 'quantitative';
+
+ let legendShow = Boolean(seriesField)
+ && (!signals.seriesCountKnown || signals.seriesCount > 1)
+ && legendSpec.show !== 'never';
+ if (legendShow && legendSpec.suppressWhenAxisNames && seriesField && seriesField === catField) {
+ legendShow = false;
+ say('legend.suppressWhenAxisNames', 'legend removed — it restated the categorical axis');
+ }
+
+ // Which placements grounding can offer at all. `inline` needs a label
+ // anchored to each mark's own geometry, which only line-family charts have
+ // room for; the ranked list exists precisely so this can fall through.
+ //
+ // A stacked band has that room too, and more of it: the name goes *inside*
+ // the band at its last reading, which is where a reader's eye already is
+ // when they ask which band this is.
+ const lineFamily = ctx.markTypes.some((m) => m === 'line' || m === 'trail');
+ // A violin or density plot is an area too, but its band has no meaningful
+ // last reading to hang a name on: the shape is a distribution, and its
+ // right edge is an arbitrary tail near zero, not an endpoint the eye rests
+ // at. Grounding withholds the in-band placement from it, and the name falls
+ // through to the ranked list — a legend — where the shapes stay legible.
+ const bandFamily = ctx.markTypes.some((m) => m === 'area') && !signals.isSummarised;
+ const placementRealizable = (p: LegendPlacement): boolean => {
+ if (p === 'seriesEnd' || p === 'inline') return (lineFamily || bandFamily) && !signals.isFaceted;
+ return true;
+ };
+ let placement: LegendPlacement = 'right';
+ let fallbacks: LegendPlacement[] = [];
+ for (const [i, p] of ranked.entries()) {
+ if (placementRealizable(p)) {
+ placement = p;
+ fallbacks = ranked.slice(i + 1).filter(placementRealizable);
+ break;
+ }
+ say('legend.placement', `\`${p}\` not available for this chart — falling through`);
+ }
+
+ const legendOrient = placement === 'inside'
+ ? 'top-right'
+ : placement === 'seriesEnd' || placement === 'inline'
+ ? 'none'
+ : (placement as 'top' | 'right' | 'bottom' | 'left');
+
+ // A chart that prints each series' name at its right-hand end (seriesEnd /
+ // inline placement on a left-to-right axis) has claimed the right margin.
+ // A house that also seats its measure axis on the right (opposite
+ // placement) would stack that axis's tick labels under the names —
+ // "Uni1ted", "Ch2na", "Ja3an" on a bump chart's rank axis. The end labels
+ // own the right side; the measure axis falls back to the left.
+ if (placement === 'seriesEnd' || placement === 'inline') {
+ for (const ch of bindings.measureChannels) {
+ const ax = axes[ch];
+ if (ax && ax.orient === 'right') {
+ ax.orient = 'left';
+ say('axes.measure.placement',
+ 'the series names sit at the line ends on the right — the measure axis moves to the left so its ticks do not land under the names');
+ }
+ }
+ }
+
+ // A key to a set of names needs no title: `Chrome`, `Safari`, `Firefox`
+ // say what kind of thing they are, and `Browser` written over them repeats
+ // it. A ramp of numbers says nothing of the sort — `26` is an instance of
+ // nothing until the key names what it counts. `whenAmbiguous` is that
+ // question, and it is asked of the field, not answered with a constant.
+ const titlePolicy = legendSpec.title ?? 'whenAmbiguous';
+ const keyNamesItself = labelsNameThemselves(ctx, bindings.seriesChannel);
+ const legendTitle = titlePolicy === 'always'
+ || (titlePolicy === 'whenAmbiguous' && legendShow && !keyNamesItself);
+ if (legendTitle && titlePolicy !== 'always') {
+ say('legend.title',
+ 'the key is a ruler, not a list of names — without a title nothing says what its numbers count');
+ }
+
+ // --- data labels --------------------------------------------------------
+ const dl = theme.dataLabels ?? {};
+ let dlPlacement = dl.placement ?? 'outsideMark';
+ // A label placed *on* a mark sits on the mark's fill, whatever the house
+ // said about ink. Contrast is a legibility floor, not a style choice.
+ const dlInkMode = dl.inkMode
+ ?? (dlPlacement === 'atMark' ? 'contrastWithMark' : 'fixed');
+ if (!dl.inkMode && dlInkMode === 'contrastWithMark') {
+ say('dataLabels.inkMode',
+ 'no ink mode declared but the label sits on the mark — it contrasts with what it is printed on');
+ }
+ // The reader's own answer outranks the house's standing preference: `off`
+ // is a decision that this chart carries no numbers, `on` that it does.
+ // Absence leaves the house in charge — and with no house named, the neutral
+ // default is silence, so an untheme'd chart prints numbers only when asked.
+ // `on` becomes `always` rather than an unconditional print, so it inherits
+ // the density guard below — a control that can bury a chart in unreadable
+ // numbers is not a control, it is a trap.
+ const dlShowPolicy: 'always' | 'whenTheyFit' | 'never' | undefined =
+ ctx.valueLabels === 'off' ? 'never'
+ : ctx.valueLabels === 'on' ? 'always'
+ : dl.show;
+ if (ctx.valueLabels === 'on' || ctx.valueLabels === 'off') {
+ if (dlShowPolicy !== dl.show) {
+ say('dataLabels.show',
+ `the chart asked for value labels \`${ctx.valueLabels}\`, overriding the house's \`${dl.show ?? 'unset'}\``);
+ }
+ }
+ let dlShow = dlShowPolicy === 'always';
+
+ // A cell in a grid *is* a position. The reader finds it by its row and its
+ // column, and the number goes in the middle of it — the measure being on
+ // colour is the reason the number is worth printing, not a reason to
+ // withhold it.
+ // A printed value has to be *keyed* to something the eye can separate — a
+ // band, a slice, a discrete step. On a continuous-by-continuous plot there
+ // is no such anchor: every datum would get its own floating number and the
+ // result is not a labelled chart, it is a chart with numbers spilled on it.
+ // This gate binds `always` too; `always` is a house habit, not a licence.
+ // There also has to be a value to print: a heatmap has two banded axes and
+ // its measure on colour, and a number can only be printed where a measure
+ // is on a position. And where the chart summarises a distribution, the
+ // marks in a band *are* the sample: its subject is the shape, and thirty
+ // numbers per band bury it.
+ //
+ // Stacked segments are labelled, but only in the middle of the segment.
+ // The objection to labelling a stack is against the *edge*, where a number
+ // reads as the running total; a number centred in the segment reads as the
+ // segment, which is the one thing a stacked bar otherwise makes hard to
+ // get at. Whether each segment is thick enough to hold that number is a
+ // separate question, settled below.
+ //
+ // A line or an area is the exception the part-to-whole test would
+ // otherwise let through: a normalized stacked area *is* a part of a whole,
+ // but it is drawn as a continuous ribbon with no slot to print into, so
+ // the numbers land on the vertices — which are sampling points, not marks
+ // a reader is meant to read off one at a time. And on a normalized chart
+ // they name a quantity the axis does not carry: the axis is a percentage
+ // and the number is a raw total.
+ const continuousMark = ctx.markTypes.some((m) => m === 'area' || m === 'line' || m === 'trail');
+ const labelable = ((signals.hasBandedAxis && (bindings.measureChannels.length > 0 || gridCells))
+ || signals.isPartToWhole)
+ && !signals.isSummarised
+ && !continuousMark
+ && !MULTI_VALUE_GLYPH_CHARTS.has(ctx.chartType);
+ if (dlShow && !labelable) {
+ dlShow = false;
+ say('dataLabels.show', signals.isSummarised
+ ? 'the chart summarises a distribution — each band holds a sample, not one quantity to print'
+ : continuousMark
+ ? 'the mark is a continuous line or ribbon — its vertices are sampling points, not marks to read off one at a time'
+ : MULTI_VALUE_GLYPH_CHARTS.has(ctx.chartType)
+ ? 'the mark carries several measures at once — there is no single value to print, and the measure axis stays as the only reading of them'
+ : signals.hasBandedAxis
+ ? 'the measure is not on an axis — there is no position to print a value at'
+ : 'no banded axis to key values to — one number per datum would be noise, not a label');
+ }
+
+ // How wide the printed number itself is. Needed before the fit checks
+ // below, not after them: on a dodged chart the number's own width is what
+ // decides whether a bar's slot can carry it, so a check that runs later
+ // can only veto a decision already reported — which is how the control
+ // came to be offered on charts that then printed nothing.
+ //
+ // What is measured is the label as it will be *printed*. Measuring
+ // `String(Math.round(value))` instead — as this did — is the width of a
+ // number nobody prints: it ignores the decimals, the separators, the sign
+ // and the format, so a chart of decimals measured four times narrower than
+ // it drew and its labels were offered straight into a pile.
+ let valueMaxAbs = 0;
+ let measureField: string | undefined;
+ const labelValues: number[] = [];
+ {
+ const mch = bindings.measureChannels[0];
+ measureField = mch
+ ? (ctx.channelSemantics[mch]?.field ?? ctx.positional?.[mch]?.field)
+ : undefined;
+ if (measureField) {
+ for (const row of ctx.table) {
+ const v = row?.[measureField];
+ if (typeof v !== 'number' || !Number.isFinite(v)) continue;
+ valueMaxAbs = Math.max(valueMaxAbs, Math.abs(v));
+ labelValues.push(v);
+ }
+ }
+ }
+ const numberFormatChoice = groundNumberFormat(theme, ctx, bindings.measureChannels[0], labelValues);
+ const numberFormat = numberFormatChoice.pattern;
+ if (numberFormatChoice.inferred && labelValues.length > 0) {
+ // Say it out loud: the digits a label carries are a decision, and a
+ // silent one would look like the number had simply been mangled.
+ const rawWidth = longestLabelChars(labelValues, undefined);
+ const shown = longestLabelChars(labelValues, numberFormat);
+ say('annotation.numberFormat',
+ `printed values use \`${numberFormat}\` — three significant figures is what a reader takes off a mark, and it holds the longest label to ${shown} characters where the raw value runs to ${rawWidth}`);
+ }
+ // A normalized stack prints each segment's share, not its value, so that
+ // is the string whose width has to fit — never wider than `100%`, however
+ // large the underlying numbers are.
+ const normalizedStack = (ctx.stacked ?? undefined) === 'normalize';
+ const labelChars = normalizedStack
+ ? 4
+ : longestLabelChars(labelValues, numberFormat);
+ const valueLabelWidthPx = (valueLabel.fontSize ?? 10) * 0.62 * labelChars + 12;
+ // The ink alone, without the breathing room a label wants when it has to
+ // sit *inside* something. Two labels floating above their own bars only
+ // need to clear each other.
+ const labelTextPx = (valueLabel.fontSize ?? 10) * 0.62 * labelChars;
+
+ // Both policies read fit from the same two facts — room enough to stand a
+ // number in, and few enough marks that the numbers do not pile up — and
+ // differ only in where they draw the line. Computing them once also gives
+ // the honest answer to "could this chart carry labels at all?", which is
+ // what a host needs to decide whether offering the control is meaningful.
+ const labelBand = signals.hasBandedAxis
+ ? (bindings.categoricalChannel === 'y' ? ctx.layout.yStep : ctx.layout.xStep)
+ : Infinity;
+ // A dodged chart splits its band between the series with nothing between
+ // them, so the room a single number gets is the band over the series count
+ // — not the band. A single series keeps the whole band and can lean a
+ // number into the padding on either side.
+ const dodged = signals.seriesCount > 1
+ && (bindings.categoricalChannel === 'y'
+ ? ctx.layout.yStepUnit === 'group'
+ : ctx.layout.xStepUnit === 'group');
+ const labelSlot = dodged ? labelBand / Math.max(1, signals.seriesCount) : labelBand;
+ const labelMarks = Math.max(1, signals.categoryCount || ctx.table.length)
+ * Math.max(1, signals.seriesCount);
+ // Which way the number has to fit depends on which way the bars run. Across
+ // a vertical bar it is the number's *width* that must clear the slot; along
+ // a horizontal one the number sits at the bar's end, so what the slot must
+ // hold is the height of a line of text.
+ const slotHoldsLine = labelSlot >= (valueLabel.fontSize ?? 10) + 4;
+ // `ctx.stacked` reports only an *explicit* stack; a bar with a colour
+ // channel is stacked by Vega-Lite without being asked, and that shows up
+ // in the positional facts. It is `||`, not `??`: the explicit reading is
+ // `false` rather than absent when nothing was stated.
+ const stacked = ctx.stacked || ctx.positional?.stacked;
+ // On a vertical bar the number lies across the band, so the band has to be
+ // at least as wide as the number is — and that holds whether or not the
+ // bar shares its band. Moving the label above the bar buys height, not
+ // width: the label above bar B still runs into the label above bar C.
+ // `,.0f` on nine-digit revenues drew `987,654,321` across three bands and
+ // off both plot edges, which is the case this closes.
+ const widthIsBinding = bindings.categoricalChannel === 'x';
+ // A label sharing its band — dodged or stacked — has to stand in the room
+ // it is given, gutter and all. One floating above its own bar only has to
+ // clear its neighbour: labels are centred on the band, so two of them
+ // touch exactly when the printed string is wider than the step.
+ const widthNeeded = (dodged || Boolean(stacked)) ? valueLabelWidthPx : labelTextPx;
+ const slotHoldsNumber = !widthIsBinding || labelSlot >= widthNeeded;
+ // A stacked bar shares its band between the segments the *other* way: the
+ // band is whole, but each segment's own thickness is what has to hold a
+ // line of text. Segments thinner than that are dropped one by one further
+ // down; what is settled here is the chart-level question — if not one
+ // segment can carry its number, there is nothing to offer the reader.
+ let segmentsFit = true;
+ let totalSegments = 0;
+ let thinSegments = 0;
+ let segmentMinShare: number | undefined;
+ if (stacked && measureField && signals.hasBandedAxis) {
+ // Along the measure axis, a segment gets the share of the plot its
+ // value has of the tallest stack — or, on a normalized chart, of its
+ // own stack, since every bar is drawn full height.
+ const extent = bindings.categoricalChannel === 'y'
+ ? ctx.layout.subplotWidth
+ : ctx.layout.subplotHeight;
+ const catField = bindings.categoricalChannel === 'y'
+ ? (ctx.positional?.y?.field ?? ctx.channelSemantics.y?.field)
+ : (ctx.positional?.x?.field ?? ctx.channelSemantics.x?.field);
+ const totals = new Map();
+ for (const row of ctx.table) {
+ const v = row?.[measureField];
+ if (typeof v !== 'number' || !Number.isFinite(v)) continue;
+ const key = catField ? row?.[catField] : '';
+ totals.set(key, (totals.get(key) ?? 0) + Math.abs(v));
+ }
+ const tallest = Math.max(0, ...totals.values());
+ const minPx = (valueLabel.fontSize ?? 10) + 4;
+ if (extent > 0) segmentMinShare = minPx / extent;
+ for (const row of ctx.table) {
+ const v = row?.[measureField];
+ if (typeof v !== 'number' || !Number.isFinite(v)) continue;
+ const key = catField ? row?.[catField] : '';
+ const against = stacked === 'normalize' ? (totals.get(key) ?? 0) : tallest;
+ if (against <= 0) continue;
+ totalSegments += 1;
+ if ((Math.abs(v) / against) * extent < minPx) thinSegments += 1;
+ }
+ segmentsFit = totalSegments === 0 || thinSegments < totalSegments;
+ }
+ const bandHoldsNumber = slotHoldsLine && slotHoldsNumber && segmentsFit;
+ // The hard ceiling: past this the numbers cannot be read whoever asked for
+ // them, so it binds `always` and an explicit `on` alike.
+ const readableAtAll = bandHoldsNumber && labelMarks <= 120;
+
+ if (dlShowPolicy === 'always' && dlShow) {
+ // `always` is a preference to print, not a licence to overprint. It
+ // holds to that preference past `whenTheyFit`'s comfort margin, onto
+ // the tight-but-legible charts the cautious houses leave to a legend,
+ // and yields only when the marks are genuinely too dense (a hundred-odd
+ // bars, a dozen-plus pie slices) for the numbers to be read.
+ if (!readableAtAll) {
+ const asked = ctx.valueLabels === 'on' ? 'the chart asked to print values, but' : '`always` overridden —';
+ dlShow = false;
+ say('dataLabels.show', !segmentsFit
+ ? `${asked} every segment is thinner than a line of text — none can hold its number`
+ : !slotHoldsNumber
+ ? (dodged
+ ? `${asked} the bars group ${signals.seriesCount} to a band — each is ${Math.round(labelSlot)}px wide, too narrow to carry a ${Math.round(valueLabelWidthPx)}px number without it landing on the next bar`
+ : `${asked} the bars are ${Math.round(labelSlot)}px wide and the number is ${Math.round(valueLabelWidthPx)}px — it would overrun the bar it belongs to`)
+ : !slotHoldsLine
+ ? `${asked} a ${Math.round(labelSlot)}px slot cannot hold a number`
+ : `${asked} ${labelMarks} marks would pile the numbers past reading`);
+ }
+ }
+
+ if (dlShowPolicy === 'whenTheyFit') {
+ dlShow = labelable && bandHoldsNumber && labelMarks <= 40;
+ if (!dlShow) {
+ say('dataLabels.show', labelable
+ ? `\`whenTheyFit\` resolved to false (slot ${Math.round(labelSlot)}px, ${labelMarks} marks)`
+ : '`whenTheyFit` resolved to false — no banded axis to key values to');
+ }
+ }
+ // A stacked segment's number belongs in the middle of the segment and
+ // nowhere else. Outside the mark is the top of the *stack*, which is a
+ // different quantity, and the segment edge is the running total — the very
+ // reading a stacked label has to avoid.
+ if (dlShow && stacked && dlPlacement !== 'atMark') {
+ say('dataLabels.placement',
+ `\`${dlPlacement}\` printed in the segment instead — outside a stacked bar is the top of the stack, not the end of the segment`);
+ dlPlacement = 'atMark';
+ }
+ // A number is printed across a bar's *width*, not up its height. A single
+ // bar too narrow for its own number does not lose the number — it moves it
+ // above the bar, where the gaps between bars give it room. (A dodged chart
+ // cannot do this: there are no gaps to move into, which is why that case is
+ // settled above, as a question of whether to label at all. Nor can a
+ // stacked one: above the bar means above the whole stack.)
+ if (dlShow && bindings.categoricalChannel === 'x' && signals.hasBandedAxis
+ && valueMaxAbs > 0 && !dodged && !stacked && valueLabelWidthPx > labelSlot
+ && dlPlacement === 'atMark') {
+ dlPlacement = 'outsideMark';
+ say('dataLabels.placement',
+ `the bar is ${Math.round(labelSlot)}px wide but the number is ${Math.round(valueLabelWidthPx)}px — it moves above the bar, where the gaps between bars give it room`);
+ }
+
+ if (dlShow && legendShow && legendSpec.suppressWhenValuesPrinted) {
+ // A printed value is not a name. It replaces a legend that was itself
+ // a value key — a ramp — but never one that carried series names, and
+ // a banded axis does not help: it names the category, not the series.
+ if (seriesIsValueKey) {
+ legendShow = false;
+ say('legend.suppressWhenValuesPrinted', 'legend removed — the ramp was a value key and every mark now prints its value');
+ } else {
+ say('legend.suppressWhenValuesPrinted',
+ 'legend kept — the values are printed but nothing else names the series');
+ }
+ }
+
+ // The same argument, one axis over: once every mark states its own value,
+ // the measure axis is a second copy of the same information.
+ if (dlShow && structure.axis?.measure?.suppressWhenValuesPrinted) {
+ for (const ch of bindings.measureChannels) {
+ const ax = axes[ch];
+ if (!ax) continue;
+ ax.label.show = false;
+ ax.grid = { show: false, color: 'transparent', width: 0 };
+ ax.title = { ...ax.title, show: false };
+ ax.ticks = { ...ax.ticks, show: false, size: 0 };
+ ax.domain = { ...ax.domain, show: false };
+ }
+ say('structure.axis.measure.suppressWhenValuesPrinted',
+ 'measure axis removed — every mark prints its own value');
+ }
+
+ const measureChannel = bindings.measureChannels[0];
+
+ // A unit has to be stated somewhere. Normally that is the ruler; a pie has
+ // no ruler, and a measure axis whose labels were removed is no longer one
+ // either. Where the house asks for a unit and nothing else can hold it, the
+ // printed value takes it — whoever printed it, the theme or the template.
+ const valueUnitChannel = measureChannel
+ ?? (['theta', 'size', 'radius'] as const).find((ch) => ctx.channelSemantics?.[ch]?.field);
+ const axisStatesUnit = (['x', 'y'] as const)
+ .some((ch) => axes[ch]?.unit && axes[ch]!.label.show !== false);
+ const houseStatesUnit = (theme.annotation?.unit ?? 'never') !== 'never' && !axisStatesUnit;
+ // A part-to-whole value whose slices sum to 100 *is* a percentage — the `%`
+ // is the number's meaning, not a house-style flourish, and a pie has no
+ // ruler to carry it. So it rides on the printed value whatever the house's
+ // axis-unit policy: a bare `65` on a slice reads as a count, not a share.
+ // (`percentOfWhole` only fires on values that actually total 100, so a pie
+ // of raw amounts keeps its bare numbers.)
+ const shareUnit = signals.isPartToWhole && !axisStatesUnit
+ ? percentOfWhole(ctx, valueUnitChannel ?? '')
+ : undefined;
+ const valueUnit = houseStatesUnit
+ ? (unitText(ctx, valueUnitChannel ?? '') ?? shareUnit)
+ : shareUnit;
+
+ // A label placed at the mark sits *inside* it, which only works while the
+ // mark is longer than the label. Below that length the label has to move
+ // out, and above the point where the mark reaches the end of the scale an
+ // outside label has nowhere left to go. Grounding is the stage that can
+ // say where those two lines are.
+ let insideMinValue: number | undefined;
+ let outsideMaxValue: number | undefined;
+ if (dlShow && measureChannel) {
+ const span = measureChannel === 'x' ? ctx.layout.subplotWidth : ctx.layout.subplotHeight;
+ if (valueMaxAbs > 0 && span > 0) {
+ insideMinValue = (valueLabelWidthPx / span) * valueMaxAbs;
+ outsideMaxValue = valueMaxAbs - insideMinValue;
+ }
+ }
+
+ // --- marks --------------------------------------------------------------
+ const marksSpec = theme.marks ?? {};
+ const separatorInk = marksSpec.separator?.source === 'surface'
+ ? plot
+ : structureInk.rule ?? structureInk.grid;
+
+ const separator = marksSpec.separator
+ ? {
+ show: (marksSpec.separator.presence ?? 'omit') !== 'omit',
+ color: (marksSpec.separator.source === 'surface'
+ ? plot
+ : ink(marksSpec.separator.presence, separatorInk, 'hairline')) ?? plot,
+ width: marksSpec.separator.width ?? 1,
+ }
+ : undefined;
+
+ // A house that says nothing about its wedges is not silent — it has
+ // already said how it holds adjoining marks apart, and a pie is adjoining
+ // marks. Only a house that wants a different answer for the circle than it
+ // gave for the bars has to say so twice.
+ const sliceGap = marksSpec.slice?.gap ?? (separator?.show ? separator.width : undefined);
+
+ // And a grid of cells is adjoining marks too. The one thing it does not
+ // inherit is the ink: a bar's edge may be drawn in structure without
+ // anyone reading a value into it, but on a grid the fill *is* the value,
+ // so an edge in any ink but the surface's adds a colour the scale never
+ // named. A house that wants framed cells asks for them.
+ const tileGap = marksSpec.tile?.gap ?? (separator?.show ? separator.width : undefined);
+
+ // A dot on a line is drawn over whatever the line passed through on its
+ // way there, and where several series share a plot that is another
+ // series' colour. A ring of the page around the dot is what holds the two
+ // apart: without it a crossing reads as one blob, and the reader cannot
+ // say which line the dot belongs to. That is a fact about lines meeting,
+ // not a matter of taste, so a chart that can have crossings gets the ring
+ // whether or not the house thought to name one — a house that wants none
+ // says `halo: { presence: 'omit' }`.
+ const crossingField = ctx.channelSemantics.color?.field
+ ?? ctx.channelSemantics.detail?.field
+ ?? ctx.positional?.color?.field;
+ const linesCanCross = ctx.markTypes.includes('line')
+ && !!crossingField
+ && new Set(ctx.table.map((r) => r?.[crossingField!]).filter((v) => v != null)).size > 1;
+ const haloDeclared = marksSpec.point?.halo?.presence !== undefined;
+ const halo = haloDeclared
+ ? marksSpec.point!.halo!.presence !== 'omit'
+ : linesCanCross;
+ if (halo && !haloDeclared) {
+ say('marks.point.halo',
+ 'the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on');
+ }
+
+ const marks: DesignDecisions['marks'] = {
+ bandFraction: marksSpec.bandFraction ?? (1 - (ctx.layout.stepPadding ?? 0.1)),
+ strokeWidth: marksSpec.strokeWeight ?? 2,
+ strokeCap: marksSpec.strokeCap,
+ strokeJoin: marksSpec.strokeJoin,
+ interpolate: marksSpec.interpolation === 'monotone'
+ ? 'monotone'
+ : marksSpec.interpolation === 'step' ? 'step' : undefined,
+ fillOpacity: marksSpec.fillOpacity,
+ cornerRadius: marksSpec.cornerRadius,
+ outline: marksSpec.outline && (marksSpec.outline.presence ?? 'omit') !== 'omit'
+ ? {
+ color: marksSpec.outline.source === 'surface'
+ ? plot
+ : (ink('full', structureInk.axis ?? structureInk.rule, 'full') ?? foreground),
+ width: marksSpec.outline.weight ?? 1.5,
+ }
+ : undefined,
+ point: marksSpec.point || halo
+ ? {
+ show: (marksSpec.point?.presence ?? 'omit') !== 'omit',
+ size: marksSpec.point?.size,
+ // Only a house that spoke about its dots' fill decides how
+ // they are filled; inventing an answer here would re-fill
+ // every scatter it has for the sake of a line chart's
+ // vertices — or, for a house that named only a size, turn
+ // the hollow glyphs of a shape-encoded scatter solid.
+ filled: marksSpec.point?.fill != null
+ ? marksSpec.point.fill !== 'hollow'
+ : undefined,
+ haloColor: halo ? plot : undefined,
+ haloWidth: marksSpec.point?.halo?.width ?? (halo ? 1.5 : undefined),
+ }
+ : undefined,
+ separator,
+ slice: sliceGap
+ ? {
+ gap: sliceGap,
+ style: marksSpec.slice?.gapStyle ?? 'rule',
+ color: separator?.color ?? plot,
+ }
+ : undefined,
+ tile: tileGap
+ ? {
+ gap: tileGap,
+ color: marksSpec.tile?.source === 'structure'
+ ? (ink('hairline', structureInk.rule, 'hairline') ?? plot)
+ : plot,
+ }
+ : undefined,
+ connector: {
+ // A house that names no connector has still not asked for its
+ // dumbbell's bridge to be drawn in a series colour. `show` says
+ // whether the house styles connectors at all; stage 3 uses it to
+ // separate the roles it may restyle freely from the one it must
+ // correct either way. The ink is resolved for both cases, so an
+ // undeclared house gets the same quiet structural grey a declared
+ // one would have got by saying nothing about its ink.
+ show: marksSpec.connector ? (marksSpec.connector.presence ?? 'omit') !== 'omit' : false,
+ // A connector is not a gridline. It borrows the rule's ink
+ // where the house declares none of its own, but it is read as
+ // part of the mark, not through it — so it is scaled at the
+ // step the house named against whichever of the two inks it
+ // gave, and a house whose rules are already pale states a
+ // `connector` ink rather than fading a faint grey further.
+ //
+ // Where the house declares no connector at all there is no step
+ // to scale and no ink to borrow that is not the grid's, and grid
+ // ink is too faint: a bridge carries the reading, so it has to
+ // sit clearly above the lines drawn *through* the plot even while
+ // it stays below the marks. The three houses that do state a
+ // connector ink put it at very nearly the same place — about
+ // 45% of the way from the axis-label ink toward the plot surface
+ // (mckinsey 0.48, powerbi 0.47, powerbi-light 0.26) — so a silent
+ // house is given the same relationship against its own two inks.
+ color: marksSpec.connector
+ ? ink(
+ marksSpec.connector.presence,
+ structureInk.connector ?? structureInk.rule,
+ 'quiet',
+ ) ?? undefined
+ : mixHex(axisLabelText.color ?? foreground, plot, 0.45, foreground),
+ width: marksSpec.connector?.weight ?? 1,
+ // A stem and a bridge are one setting only in the sense that
+ // both are drawn in structure's ink. What they are worth
+ // differs: a stem repeats a position already plotted, a bridge
+ // draws a distance that is plotted nowhere else. So a house
+ // that says nothing about the bridge is not silent about it
+ // either — it has already said what a mark of its own weighs.
+ spanWidth: marksSpec.connector?.spanWeight ?? (marksSpec.strokeWeight ?? 2),
+ ...(marksSpec.connector?.style && marksSpec.connector.style !== 'solid'
+ ? { dash: marksSpec.connector.style === 'dotted' ? [1, 2] : [4, 3] }
+ : {}),
+ },
+ interval: marksSpec.interval
+ ? {
+ fillOpacity: marksSpec.interval.fillOpacity,
+ edge: (marksSpec.interval.edge ?? 'omit') !== 'omit',
+ }
+ : undefined,
+ summary: marksSpec.summary
+ ? {
+ fill: (marksSpec.summary.fill ?? 'full') !== 'omit',
+ outline: (marksSpec.summary.outline ?? 'full') !== 'omit',
+ centralRule: (marksSpec.summary.centralRule ?? 'full') !== 'omit',
+ widthFraction: marksSpec.summary.widthFraction,
+ }
+ : undefined,
+ reference: marksSpec.reference
+ ? {
+ show: (marksSpec.reference.presence ?? 'omit') !== 'omit',
+ width: marksSpec.reference.weight ?? 1,
+ style: marksSpec.reference.style,
+ label: marksSpec.reference.label === true,
+ }
+ : undefined,
+ zOrder: marksSpec.zOrder ?? 'summaryOverData',
+ sizeRange: marksSpec.sizeRange,
+ minSize: marksSpec.minSize,
+ observations: marksSpec.observations
+ ? {
+ expose: marksSpec.observations.expose ?? 'never',
+ maxRows: marksSpec.observations.maxRows ?? 500,
+ }
+ : undefined,
+ redundantChannels: marksSpec.redundantChannels ?? [],
+ redundantEncoding: marksSpec.redundantEncoding ?? 'never',
+ redundant: groundRedundancy(marksSpec, series, signals,
+ legendShow && (placement === 'seriesEnd' || placement === 'inline'), say),
+ };
+
+ // --- facets -------------------------------------------------------------
+ const facetSpec = theme.facets ?? {};
+ const headerPresence = facetSpec.header?.presence ?? 'full';
+ const facets: DesignDecisions['facets'] = {
+ header: {
+ show: headerPresence !== 'omit',
+ fieldTitle: (facetSpec.header?.fieldTitle ?? 'omit') === 'always',
+ ...keyLabel,
+ color: headerPresence === 'emphasised' ? text.primary : keyLabel.color,
+ },
+ panelFrame: (facetSpec.panelFrame ?? 'omit') !== 'omit',
+ axisRepetition: facetSpec.axisRepetition ?? 'everyPanel',
+ spacing: facetSpec.spacing === 'compact' ? 8 : facetSpec.spacing === 'airy' ? 24 : undefined,
+ preferredColumns: facetSpec.preferredColumns,
+ };
+
+ // --- layout -------------------------------------------------------------
+ const density = theme.layout?.density ?? 'normal';
+ const densityPadding = density === 'compact' ? 8 : density === 'airy' ? 20 : 12;
+
+ // A house that paints its canvas has drawn a rectangle, and the padding
+ // stops being empty space: it becomes that rectangle's margin, a visible
+ // edge with the ink measured against it. On plain white the same number is
+ // invisible — the page's whitespace runs straight through it, so ink
+ // sitting 8px from the boundary still looks like it has all the room in
+ // the world, because there is no boundary to see.
+ //
+ // Against a painted edge it does not. The nearest ink to the boundary is
+ // almost always an axis tick label, and a margin narrower than the type it
+ // surrounds reads as a crop rather than a frame. That is exactly what the
+ // dark house was doing: `compact` density gave it 8px, its tick labels are
+ // 10px, and the numbers looked shaved off the bottom of the panel.
+ //
+ // So a painted canvas is held to a floor of one and a half label heights
+ // on all four sides — the usual margin for framed type, and derived from
+ // the type rather than picked, because it is that type the margin has to
+ // clear. A house already breathing wider than the floor keeps its own
+ // number: density is still the house's voice, and this only stops that
+ // voice from cropping itself.
+ const padding = isPaintedSurface(canvas)
+ ? Math.max(densityPadding, Math.round((axisLabelText.fontSize ?? 10) * 1.5))
+ : densityPadding;
+
+ return {
+ themeId: theme.id ?? 'flint',
+ surface: { canvas, plot, panel },
+ text,
+ font: bodyFamily,
+ title: {
+ anchor: theme.layout?.titleBlock?.anchor ?? 'start',
+ position: theme.layout?.titleBlock?.position ?? 'top',
+ headline,
+ deck,
+ offset: Math.round((headline.fontSize ?? 14) * TITLE_GAP[theme.layout?.titleBlock?.gap ?? 'normal']),
+ deckPadding: Math.round((deck.fontSize ?? 11) * DECK_GAP[theme.layout?.titleBlock?.deckGap ?? 'normal']),
+ },
+ axes,
+ frame,
+ baseline,
+ series,
+ legend: {
+ show: legendShow,
+ placement,
+ ...(fallbacks.length ? { fallbacks } : {}),
+ orient: legendOrient,
+ direction: theme.legend?.direction
+ ?? (legendOrient === 'top' || legendOrient === 'bottom' ? 'horizontal' : 'vertical'),
+ title: legendTitle,
+ label: keyLabel,
+ gradientLength: legendSpec.gradientLength,
+ maxSwatches: legendSpec.maxSwatches,
+ },
+ dataLabels: {
+ show: dlShow,
+ possible: labelable && readableAtAll,
+ placement: dlPlacement,
+ inkMode: dlInkMode,
+ text: valueLabel,
+ format: numberFormat,
+ ...(valueUnit ? { unit: valueUnit } : {}),
+ insideMinValue,
+ outsideMaxValue,
+ ...(segmentMinShare !== undefined ? { segmentMinShare } : {}),
+ },
+ // A house that dots the end of a line is saying where the story stops.
+ // Only the policy is decided here: whether the chart *has* a line to
+ // dot is a question about the backend spec, and stage 3 answers it.
+ pointEmphasis: (theme.annotation?.pointEmphasis ?? 'never') !== 'never'
+ ? {
+ where: theme.annotation!.pointEmphasis as 'endpoints' | 'latest' | 'extremes',
+ labels: theme.annotation?.pointLabels ?? 'never',
+ size: (marks.strokeWidth || 2) * 14,
+ }
+ : undefined,
+ marks,
+ facets,
+ layout: { padding, density },
+ statistics: theme.annotation?.statistics?.show?.length
+ ? {
+ show: theme.annotation.statistics.show,
+ placement: theme.annotation.statistics.placement ?? 'panel',
+ ...axisLabelText,
+ }
+ : undefined,
+ furniture: theme.furniture ?? [],
+ bound: {
+ measureChannels: bindings.measureChannels,
+ categoricalChannel: bindings.categoricalChannel,
+ seriesChannel: bindings.seriesChannel,
+ seriesField,
+ categoryField: catField,
+ seriesCount: signals.seriesCount,
+ categoryCount: signals.categoryCount,
+ isFaceted: signals.isFaceted,
+ isPartToWhole: signals.isPartToWhole,
+ isSigned: signals.isSigned,
+ markChannel: signals.markChannel,
+ },
+ report,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Series ink
+// ---------------------------------------------------------------------------
+
+/** WCAG contrast between two colours, `1` for identical. */
+function contrastRatio(a: string, b: string): number {
+ const ca = parseColor(a);
+ const cb = parseColor(b);
+ if (!ca || !cb) return 21;
+ const la = luminance(ca);
+ const lb = luminance(cb);
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
+}
+
+/** The least contrast at which a filled cell still reads as a cell. */
+const ENDPOINT_CONTRAST = 1.2;
+
+/**
+ * Keep a ramp's ends off the page.
+ *
+ * A ramp that starts a shade away from the surface makes its smallest values
+ * disappear, and “least” then looks like “no data”. Where the house asks for
+ * endpoints that stand against the surface, an end too close to it is pulled
+ * away until it can be seen — the hue is the house's, only its distance from
+ * the page is not.
+ */
+function offSurface(
+ ramp: Ramp | undefined,
+ surface: string,
+ say: (path: string, message: string) => void,
+): Ramp | undefined {
+ if (!ramp?.stops?.length || ramp.endpointsAgainstSurface !== true) return ramp;
+ const away = isDarkSurface(surface) ? '#ffffff' : '#000000';
+ const stops = ramp.stops.slice();
+ let moved = false;
+ for (const i of [0, stops.length - 1]) {
+ if (contrastRatio(stops[i], surface) >= ENDPOINT_CONTRAST) continue;
+ for (let t = 0.05; t <= 0.6; t += 0.05) {
+ const candidate = mixHex(stops[i], away, t, stops[i]);
+ if (contrastRatio(candidate, surface) >= ENDPOINT_CONTRAST) {
+ stops[i] = candidate;
+ moved = true;
+ break;
+ }
+ }
+ }
+ if (!moved) return ramp;
+ say('ink.series.endpointsAgainstSurface',
+ 'a ramp end sat too close to the surface to be seen as a value — it was pulled away from the page');
+ return { ...ramp, stops };
+}
+
+function groundSeriesInk(
+ theme: ThemeSpec,
+ ctx: GroundingContext,
+ bindings: Bindings,
+ signals: Signals,
+ say: (path: string, message: string) => void,
+): ResolvedSeriesInk {
+ const s = theme.ink?.series ?? {};
+ const selection = s.selection ?? {};
+ const categorical = s.categorical ?? [];
+ const extended = s.categoricalExtended ?? [];
+ const single = s.single ?? categorical[0] ?? theme.ink?.accent ?? '#4c78a8';
+ const surfaceColour = theme.ink?.surface?.plot ?? theme.ink?.surface?.canvas ?? '#ffffff';
+
+ // The house may name a larger indexed set for higher cardinality (Tableau
+ // 10→20). Rank the tiers by capacity so we can reach for the smallest one
+ // that still gives every series its own ink.
+ const tiers = [categorical, extended]
+ .filter((t) => t.length > 0)
+ .sort((a, b) => a.length - b.length);
+ const largestTier = tiers.length ? tiers[tiers.length - 1] : categorical;
+
+ const seriesChannel = bindings.seriesChannel;
+ const seriesField = channelFact(ctx, seriesChannel)?.field;
+ const seriesType = channelFact(ctx, seriesChannel)?.type;
+ const count = signals.seriesCount;
+
+ const base: ResolvedSeriesInk = {
+ mode: 'single',
+ single,
+ categorical,
+ overflow: s.overflow,
+ status: s.status,
+ };
+
+ if (!seriesField || (signals.seriesCountKnown && count <= 1)) {
+ // A single-series chart still needs an ink, and there is exactly one
+ // right answer: the house's single-series colour.
+ return base;
+ }
+ if (!signals.seriesCountKnown) {
+ say('ink.series',
+ `\`${seriesField}\` is created by a backend transform — the whole categorical set is offered rather than guessing a count`);
+ }
+
+ const facetField = bindings.facetChannel ? ctx.channelSemantics[bindings.facetChannel]?.field : undefined;
+ if (selection.redundantWithFacet === 'single' && facetField && facetField === seriesField) {
+ say('ink.series.selection.redundantWithFacet',
+ 'series colour collapsed to single — the facet already names the series');
+ return base;
+ }
+
+ // Continuous series: a ramp, not an indexed set.
+ if (seriesType === 'quantitative') {
+ const diverging = signals.isSigned && Boolean(s.diverging);
+ const ramp: Ramp | undefined = offSurface(diverging ? s.diverging : s.sequential, surfaceColour, say);
+ if (ramp?.stops?.length) {
+ const consumption = ramp.consumption ?? 'interpolate';
+ const quantize = consumption === 'quantize' ? (ramp.quantizeCount ?? 5) : undefined;
+ return {
+ ...base,
+ mode: diverging ? 'diverging' : 'sequential',
+ ramp,
+ quantize,
+ range: quantize ? sampleRamp(ramp.stops, quantize) : ramp.stops.slice(),
+ };
+ }
+ say('ink.series', 'no ramp declared for a continuous series — one is built from the house ink');
+ // An indexed set is never the right answer for a continuous field: it
+ // says "different" where the data says "more". If the house has not
+ // declared a ramp, the honest fallback is a ramp of its own colour,
+ // from a tint of it to the colour itself.
+ const surface = theme.ink?.surface?.canvas ?? '#ffffff';
+ const stops = [mixHex(single, surface, 0.85), single];
+ return {
+ ...base,
+ mode: 'sequential',
+ ramp: { stops },
+ range: stops,
+ };
+ }
+
+ if (signals.isPartToWhole && selection.partToWhole === 'sequentialRamp' && s.sequential?.stops?.length) {
+ // A single-hue ramp names a part-to-whole cleanly only while the slices
+ // stay as few as the ramp has control points. Sampled past that, the
+ // adjacent shades blur into one another and the wheel reads as a smear
+ // of near-identical tints — worse than distinct hues, and it also skips
+ // the "Others" tail a large pie needs. Up to the ramp's resolution the
+ // house keeps its monochrome part-to-whole look; beyond it, fall through
+ // to the indexed set (distinct hues + an Others fold) so every slice
+ // stays nameable.
+ const rampResolution = s.sequential.stops.length;
+ if (!signals.seriesCountKnown || count <= rampResolution) {
+ // One ramp, consumed as an indexed set: the largest share takes the
+ // darkest end, so the ramp is sampled in reverse.
+ const ramp = offSurface(s.sequential, surfaceColour, say)!;
+ const range = sampleRamp(ramp.stops, Math.max(2, count)).reverse();
+ return { ...base, mode: 'sequential', ramp, range };
+ }
+ say('ink.series.selection.partToWhole',
+ `${count} slices exceed the ${rampResolution}-stop ramp's resolution — distinct hues name them better than shades, so the indexed set stands`);
+ }
+
+ if (signals.isSigned && s.status && selection.signed === 'status' && selection.statusUse !== 'never') {
+ if (selection.statusUse === 'thresholdOnly') {
+ say('ink.series.selection.statusUse',
+ 'status ink withheld — `thresholdOnly` and no threshold was declared');
+ } else {
+ return { ...base, mode: 'status' };
+ }
+ }
+
+ if (signals.isSigned && selection.signed === 'diverging' && s.diverging?.stops?.length) {
+ const ramp = offSurface(s.diverging, surfaceColour, say)!;
+ return {
+ ...base,
+ mode: 'diverging',
+ ramp,
+ range: sampleRamp(ramp.stops, Math.max(2, count)),
+ };
+ }
+
+ // An *ordered* series is not an indexed set. Categories that run from
+ // "a great deal" to "none at all" have a direction, and an unordered
+ // palette throws it away. One ramp, sampled to the number of steps.
+ if (seriesType === 'ordinal') {
+ const ramp: Ramp | undefined = offSurface(
+ s.sequential?.stops?.length ? s.sequential : s.diverging, surfaceColour, say);
+ if (ramp?.stops?.length && signals.seriesCountKnown) {
+ say('ink.series', 'the series is ordered — the house ramp is sampled across it rather than an unordered set');
+ return {
+ ...base,
+ mode: 'sequential',
+ ramp,
+ range: sampleRamp(ramp.stops, Math.max(2, count)),
+ };
+ }
+ }
+
+ if (signals.seriesCountKnown && count > categorical.length && categorical.length > 0) {
+ // Auto-upsize: an extended tier that still names every series is the
+ // right answer — reach for the smallest one that covers the count.
+ const fittingTier = tiers.find((t) => count <= t.length);
+ if (fittingTier && fittingTier.length > categorical.length) {
+ say('ink.series.categorical',
+ `${count} series past the core ${categorical.length} inks — the house's extended ${fittingTier.length}-colour set is used so each stays distinct`);
+ return { ...base, categorical: fittingTier, mode: 'categorical' };
+ }
+
+ if (s.overflow) {
+ // Past even the extended set. The top inks by prominence name the
+ // largest series; every remaining ("other") series folds into the
+ // one overflow ink. Realization orders the domain by share so it is
+ // the smallest series that go grey, read as a single tail.
+ say('ink.series.categorical',
+ `${count} series past the house's ${largestTier.length} inks — the largest ${largestTier.length} keep a colour, the rest fold into one "other" ink`);
+ return { ...base, categorical: largestTier, mode: 'categorical', overflowTail: true };
+ } else if (ctx.namesOnMarks === true) {
+ // The chart prints the series name on the mark (a slopegraph's end
+ // labels, a house that asked for it), so the names are already on
+ // the page in words. Colour was never the key here; keeping a
+ // foreign palette only spreads seven hues across seven lines and
+ // seven labels that say the same thing the words do. One ink, and
+ // the reader reads the names.
+ say('ink.series.categorical',
+ `${count} series against ${largestTier.length} house inks, but the house names them on the mark — colour stops naming and takes the single ink`);
+ return { ...base, mode: 'single' };
+ } else {
+ // An indexed set has a capacity, and past it the colours stop
+ // being names: two different series come out the same ink and the
+ // key lies. A house that declares six and no overflow ink has not
+ // said what the twenty-fifth thing looks like, and cycling is not
+ // an answer — it is the same answer twice.
+ say('ink.series.categorical',
+ `${count} series and the house declares ${largestTier.length} with no overflow ink — colour cannot name them all, so the scale already on the chart stands`);
+ return { ...base, categorical: largestTier, mode: 'categorical', exhausted: true };
+ }
+ }
+ return { ...base, mode: 'categorical' };
+}
+
+// ---------------------------------------------------------------------------
+// Redundant encoding
+// ---------------------------------------------------------------------------
+
+/**
+ * Shape and dash exist to carry the series identity when colour cannot: in
+ * mono print, for a colour-blind reader, or simply when there are more series
+ * than the house has inks. They are only meaningful for an indexed set — a
+ * ramp is read as a quantity, and doubling it with shapes says nothing.
+ */
+function groundRedundancy(
+ marksSpec: NonNullable,
+ series: ResolvedSeriesInk,
+ signals: Signals,
+ directlyLabeled: boolean,
+ say: (path: string, message: string) => void,
+): { shape: boolean; dash: boolean } {
+ const off = { shape: false, dash: false };
+ const policy = marksSpec.redundantEncoding ?? 'never';
+ const channels = marksSpec.redundantChannels ?? [];
+ if (policy === 'never' || channels.length === 0) return off;
+ if (series.mode !== 'categorical') return off;
+ const effectiveCount = signals.seriesCountKnown ? signals.seriesCount : series.categorical.length;
+ if (effectiveCount <= 1) return off;
+
+ if (policy === 'whenNeeded') {
+ const strained = effectiveCount > series.categorical.length;
+ if (!strained) {
+ say('marks.redundantEncoding',
+ '`whenNeeded` withheld — the house has a distinct ink for every series');
+ return off;
+ }
+ // Even with more series than inks, a redundant channel earns its noise
+ // only if the reader needs it to tell the series apart. When each series
+ // is named at its own mark — a line labelled at its end, a band at its
+ // last reading — that identity is already carried, and a dash spread
+ // over a dense path degrades into texture rather than a distinguishing
+ // mark. The name does the work `whenNeeded` was reaching for.
+ if (directlyLabeled) {
+ say('marks.redundantEncoding',
+ '`whenNeeded` withheld — each series is named at its own mark, so nothing else need tell them apart');
+ return off;
+ }
+ }
+ const unsupported = channels.filter((c) => c === 'texture' || c === 'lightness');
+ if (unsupported.length) {
+ say('marks.redundantChannels', `${unsupported.join(', ')} not realizable — ignored`);
+ }
+ return { shape: channels.includes('shape'), dash: channels.includes('dash') };
+}
+
+// ---------------------------------------------------------------------------
+// Number format
+// ---------------------------------------------------------------------------
+
+/**
+ * The format a printed value is rendered with.
+ *
+ * The house states a *style* — group the thousands, use a k/M suffix, always
+ * show the sign — and a style says nothing about how many digits follow. Left
+ * open, `~s` prints `1.23457M` and a bare `,` prints `3.14159265`: the mark
+ * gets a number longer than itself and the reader gets precision they cannot
+ * use. So a house's stated precision is honoured, and a precision the house
+ * left open is inferred from the data.
+ *
+ * With no house at all there is still a format, which is the change of
+ * substance here: the alternative is Vega-Lite's raw rendering, and that is
+ * how a tidy chart ends up captioned `0.00123456`.
+ */
+function groundNumberFormat(
+ theme: ThemeSpec,
+ ctx: GroundingContext,
+ measureChannel: 'x' | 'y' | undefined,
+ values: number[],
+): { pattern: string | undefined; inferred: boolean } {
+ const nf = theme.annotation?.numberFormat;
+ const sem = measureChannel ? ctx.channelSemantics[measureChannel] : undefined;
+ const isPercent = typeof sem?.format?.suffix === 'string' && sem.format.suffix.includes('%');
+
+ let house: string | undefined;
+ if (nf) {
+ const sign = nf.signed ? '+' : '';
+ if (nf.thousands === 'suffix') house = `${sign}~s`;
+ else {
+ const group = nf.thousands === 'separator' ? ',' : '';
+ const precision = nf.precision === 'integer' ? '.0'
+ : nf.precision === 'one' ? '.1'
+ : nf.precision === 'two' ? '.2'
+ : undefined;
+ house = precision === undefined
+ ? (group ? `${sign}${group}` : (sign || undefined))
+ : `${sign}${group}${precision}${isPercent ? 'f' : 'f'}`;
+ }
+ }
+ // A field already carrying its own percent formatting is left alone: the
+ // semantics decided how that number reads, and re-deriving it here would
+ // print a share of a share.
+ if (isPercent) return { pattern: house, inferred: false };
+ const pattern = inferValueLabelFormat(values, house);
+ return { pattern, inferred: pattern !== house };
+}
+
+function clamp(v: number, lo: number, hi: number): number {
+ return Math.max(lo, Math.min(hi, v));
+}
diff --git a/packages/flint-js/src/core/theme/index.ts b/packages/flint-js/src/core/theme/index.ts
new file mode 100644
index 00000000..7b9b0b07
--- /dev/null
+++ b/packages/flint-js/src/core/theme/index.ts
@@ -0,0 +1,8 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+export * from './types.js';
+export * from './presence.js';
+export { groundTheme } from './ground.js';
+export type { GroundingContext } from './ground.js';
+export { THEME_PRESETS, DEFAULT_THEME_ICON, listThemePresets, resolveThemeSpec } from './presets.js';
diff --git a/packages/flint-js/src/core/theme/merge.ts b/packages/flint-js/src/core/theme/merge.ts
new file mode 100644
index 00000000..81240b39
--- /dev/null
+++ b/packages/flint-js/src/core/theme/merge.ts
@@ -0,0 +1,21 @@
+/** True for JSON-style records, but not arrays. */
+function isPlainObject(value: unknown): value is Record {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+/**
+ * Merge authored policy objects.
+ *
+ * Objects merge recursively. Arrays and scalar values are complete authored
+ * decisions and replace the base. `undefined` means the patch did not state a
+ * decision, which matters to TypeScript callers even though JSON cannot carry
+ * it.
+ */
+export function deepMerge(base: T, patch: unknown): T {
+ if (!isPlainObject(patch)) return (patch === undefined ? base : patch) as T;
+ const out: Record = isPlainObject(base) ? { ...base } : {};
+ for (const [key, value] of Object.entries(patch)) {
+ out[key] = isPlainObject(value) ? deepMerge(out[key], value) : (value === undefined ? out[key] : value);
+ }
+ return out as T;
+}
diff --git a/packages/flint-js/src/core/theme/presence.ts b/packages/flint-js/src/core/theme/presence.ts
new file mode 100644
index 00000000..dd0fd733
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presence.ts
@@ -0,0 +1,225 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * The presence ordinal, resolved.
+ *
+ * `omit < hairline < quiet < full < emphasised` is a statement about *contrast
+ * against the surface the element sits on*, not about opacity or colour. That
+ * is the whole reason one ThemeSpec works on white and on Power BI's #1b1a19
+ * with no branch: the ordinal is resolved late, against the surface that
+ * grounding actually chose.
+ */
+
+import type { Presence } from './types.js';
+
+export interface RGB { r: number; g: number; b: number; a: number }
+
+const HEX3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i;
+const HEX6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
+const HEX8 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
+
+export function parseColor(input: string | undefined | null): RGB | null {
+ if (!input) return null;
+ const s = String(input).trim();
+ let m = HEX8.exec(s);
+ if (m) {
+ return {
+ r: parseInt(m[1], 16),
+ g: parseInt(m[2], 16),
+ b: parseInt(m[3], 16),
+ a: parseInt(m[4], 16) / 255,
+ };
+ }
+ m = HEX6.exec(s);
+ if (m) {
+ return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16), a: 1 };
+ }
+ m = HEX3.exec(s);
+ if (m) {
+ return {
+ r: parseInt(m[1] + m[1], 16),
+ g: parseInt(m[2] + m[2], 16),
+ b: parseInt(m[3] + m[3], 16),
+ a: 1,
+ };
+ }
+ return null;
+}
+
+export function toHex(c: RGB): string {
+ const h = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
+ return `#${h(c.r)}${h(c.g)}${h(c.b)}`;
+}
+
+/** Composite a possibly-transparent ink over an opaque surface. */
+export function flatten(ink: RGB, surface: RGB): RGB {
+ if (ink.a >= 1) return { ...ink, a: 1 };
+ return {
+ r: ink.r * ink.a + surface.r * (1 - ink.a),
+ g: ink.g * ink.a + surface.g * (1 - ink.a),
+ b: ink.b * ink.a + surface.b * (1 - ink.a),
+ a: 1,
+ };
+}
+
+/** Linear blend from `a` (t=0) to `b` (t=1). */
+export function mix(a: RGB, b: RGB, t: number): RGB {
+ const k = Math.max(0, Math.min(1, t));
+ return {
+ r: a.r + (b.r - a.r) * k,
+ g: a.g + (b.g - a.g) * k,
+ b: a.b + (b.b - a.b) * k,
+ a: 1,
+ };
+}
+
+export function mixHex(a: string, b: string, t: number, fallback = '#000000'): string {
+ const ca = parseColor(a);
+ const cb = parseColor(b);
+ if (!ca || !cb) return fallback;
+ return toHex(mix(ca, cb, t));
+}
+
+/** WCAG relative luminance. */
+export function luminance(c: RGB): number {
+ const f = (v: number) => {
+ const x = v / 255;
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
+}
+
+export function isDarkSurface(surface: string): boolean {
+ const c = parseColor(surface);
+ return c ? luminance(c) < 0.45 : false;
+}
+
+/**
+ * Whether a surface is a colour the reader can see, as opposed to the absence
+ * of one.
+ *
+ * Plain white is how a house says "no surface": the chart is ink on the page,
+ * and the page's own whitespace runs straight through the chart's margin, so
+ * there is no boundary anywhere. Any other value — the dark house's near
+ * black, the cream of a print house — is a rectangle that has been painted,
+ * with an edge, and everything inside it is now measured against that edge.
+ *
+ * The test is deliberately exact rather than a luminance threshold. A cream at
+ * #fffdf5 is a hair off white and would pass any "is it light?" test, but a
+ * house that went to the trouble of naming a colour other than white meant to
+ * paint something, and the reader can see it against the page.
+ */
+export function isPaintedSurface(surface: string | undefined): boolean {
+ if (!surface) return false;
+ const c = parseColor(surface);
+ if (!c) return false;
+ return !(c.r === 255 && c.g === 255 && c.b === 255);
+}
+
+/**
+ * How far up the ordinal an element sits, expressed as the fraction of the
+ * distance from the surface to full-strength role ink.
+ *
+ * These are the numbers in `fields.md` §presence, restated as a blend factor:
+ * `full` means "the ink the house declared", and everything below it is that
+ * same ink pulled back toward the surface. Nothing here invents a hue — the
+ * hue always comes from `ink.structure.*`.
+ */
+const PRESENCE_STRENGTH: Record = {
+ omit: 0,
+ hairline: 0.42,
+ quiet: 0.72,
+ full: 1,
+ emphasised: 1,
+};
+
+/**
+ * Contrast targets used only when the house declared no ink for the role.
+ * Expressed against the foreground, so a dark surface flips automatically.
+ */
+const PRESENCE_FALLBACK_CONTRAST: Record = {
+ omit: 0,
+ hairline: 0.06,
+ quiet: 0.12,
+ full: 0.4,
+ emphasised: 1,
+};
+
+export const PRESENCE_ORDER: Presence[] = ['omit', 'hairline', 'quiet', 'full', 'emphasised'];
+
+export function presenceRank(p: Presence | undefined, dflt: Presence = 'full'): number {
+ return PRESENCE_ORDER.indexOf(p ?? dflt);
+}
+
+export interface ResolveInkArgs {
+ presence: Presence | undefined;
+ /** The surface this element sits on, already resolved. */
+ surface: string;
+ /** `ink.structure.*` for this role, if the house declared one. */
+ roleInk?: string;
+ /** Full-strength foreground, used for `emphasised` and as ink fallback. */
+ foreground: string;
+ fallback?: Presence;
+}
+
+/**
+ * Resolve a presence to a concrete colour, or `null` for `omit`.
+ *
+ * `emphasised` deliberately ignores the role ink: it means "as strong as text",
+ * and text ink is the only thing that knows what that is on this surface.
+ */
+export function resolvePresenceInk(args: ResolveInkArgs): string | null {
+ const p = args.presence ?? args.fallback ?? 'full';
+ if (p === 'omit') return null;
+
+ const surface = parseColor(args.surface) ?? { r: 255, g: 255, b: 255, a: 1 };
+ const fg = parseColor(args.foreground) ?? { r: 0, g: 0, b: 0, a: 1 };
+
+ if (p === 'emphasised') return toHex(fg);
+
+ const declared = parseColor(args.roleInk ?? undefined);
+ if (declared) {
+ // A fully transparent declared ink is the house saying "not here".
+ if (declared.a === 0) return null;
+ const target = flatten(declared, surface);
+ return toHex(mix(surface, target, PRESENCE_STRENGTH[p]));
+ }
+ return toHex(mix(surface, fg, PRESENCE_FALLBACK_CONTRAST[p]));
+}
+
+/** Stroke width implied by the ordinal, in px. */
+export function presenceWidth(p: Presence | undefined, base = 1): number {
+ switch (p ?? 'full') {
+ case 'omit': return 0;
+ case 'hairline': return Math.min(base, 0.5);
+ case 'quiet': return base;
+ case 'full': return base;
+ case 'emphasised': return base * 1.5;
+ default: return base;
+ }
+}
+
+/** Pick whichever of two inks reads better on `background`. */
+export function contrastingInk(background: string, light: string, dark: string): string {
+ const bg = parseColor(background);
+ if (!bg) return dark;
+ return luminance(bg) < 0.5 ? light : dark;
+}
+
+/** Sample `n` evenly spaced colours from a ramp's control points. */
+export function sampleRamp(stops: string[], n: number): string[] {
+ if (stops.length === 0) return [];
+ if (n <= 1) return [stops[stops.length - 1]];
+ const parsed = stops.map((s) => parseColor(s)).filter(Boolean) as RGB[];
+ if (parsed.length === 0) return [];
+ if (parsed.length === 1) return new Array(n).fill(toHex(parsed[0]));
+ const out: string[] = [];
+ for (let i = 0; i < n; i++) {
+ const t = (i / (n - 1)) * (parsed.length - 1);
+ const lo = Math.floor(t);
+ const hi = Math.min(parsed.length - 1, lo + 1);
+ out.push(toHex(mix(parsed[lo], parsed[hi], t - lo)));
+ }
+ return out;
+}
diff --git a/packages/flint-js/src/core/theme/presets.ts b/packages/flint-js/src/core/theme/presets.ts
new file mode 100644
index 00000000..9a1a24c8
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets.ts
@@ -0,0 +1,84 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * The houses Flint ships.
+ *
+ * Each is measured from a hand-authored redesign of real charts, so naming one
+ * is a claim that can be checked against the original rather than a mood.
+ * A caller who wants their own house passes a `ThemeSpec` object instead.
+ */
+
+import type { ThemePreset, ThemeSpec } from './types';
+import { FLINT_ICON } from './presets/icons';
+import { nyt } from './presets/nyt';
+import { economist } from './presets/economist';
+import { nature } from './presets/nature';
+import { mckinsey } from './presets/mckinsey';
+import { datawrapper } from './presets/datawrapper';
+import { powerbi } from './presets/powerbi';
+import { powerbiLight } from './presets/powerbi-light';
+import { swiss } from './presets/swiss';
+import { pop } from './presets/pop';
+import { cartoon } from './presets/cartoon';
+import { deepMerge } from './merge.js';
+
+export const THEME_PRESETS: Record = {
+ nyt,
+ economist,
+ swiss,
+ nature,
+ mckinsey,
+ datawrapper,
+ powerbi,
+ 'powerbi-light': powerbiLight,
+ pop,
+ cartoon,
+};
+
+/**
+ * The icon for "no house" — flint's own defaults, so a picker can offer
+ * *not* theming as a visible choice rather than an empty slot.
+ */
+export const DEFAULT_THEME_ICON = FLINT_ICON;
+
+/**
+ * The catalogue, without the specs — enough to choose by.
+ *
+ * Without the icons either: this is what an agent reads to pick a house, and a
+ * picture it cannot see costs it context it could have spent on the chart. A
+ * picker that wants icons reads them off {@link THEME_PRESETS}.
+ */
+export function listThemePresets(): Array> {
+ return Object.values(THEME_PRESETS).map(({ id, label, description }) => ({ id, label, description }));
+}
+
+/**
+ * Take what the caller put in `theme_spec` and hand back a ThemeSpec.
+ *
+ * A string names a house Flint ships; an object is the caller's own. An object
+ * may also `extend` one of those houses and state only its overrides. Nested
+ * policy objects merge, while arrays and scalar values replace the preset.
+ *
+ * An unknown name is an error rather than a silent fallback to no theme: a
+ * chart that quietly ignores the house it was asked for looks like a bug in
+ * the house.
+ */
+export function resolveThemeSpec(theme: ThemeSpec | string | undefined): ThemeSpec | undefined {
+ if (theme === undefined) return undefined;
+ if (typeof theme === 'string') return resolveThemeSpec(presetSpec(theme));
+ if (theme.extends === undefined) return theme;
+
+ const { extends: presetId, ...overrides } = theme;
+ return deepMerge(resolveThemeSpec(presetSpec(presetId))!, overrides);
+}
+
+function presetSpec(id: string): ThemeSpec {
+ const preset = THEME_PRESETS[id];
+ if (!preset) {
+ throw new Error(
+ `Unknown theme \`${id}\`. Flint ships: ${Object.keys(THEME_PRESETS).join(', ')}.`,
+ );
+ }
+ return preset.spec;
+}
diff --git a/packages/flint-js/src/core/theme/presets/cartoon.ts b/packages/flint-js/src/core/theme/presets/cartoon.ts
new file mode 100644
index 00000000..c558582a
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/cartoon.ts
@@ -0,0 +1,225 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ThemePreset } from '../types';
+import { CARTOON_ICON } from './icons';
+
+/**
+ * Cartoon — a playful, friendly house in the spirit of xkcd and modern
+ * flat-cartoon illustration.
+ *
+ * Modelled on the hand-authored mockups in the Cartoon lab (see
+ * `site/src/playground/cartoon-lab-data.ts`). Flint cannot draw the
+ * hand-wobbled "last mile" of a true xkcd plot — that is a per-pixel filter on
+ * the rendered SVG, not a chart decision — so the character is carried by the
+ * parts a theme owns and by three levers that read as *fun*:
+ *
+ * - a rounded comic typeface (Comic Sans / Comic Neue / Chalkboard fallbacks);
+ * - `marks.cornerRadius` — rounded bar tops and wedge corners (balloon/sticker
+ * shapes, not spreadsheet rectangles);
+ * - `marks.outline` — a fat dark border around every filled shape, including
+ * dots (the sticker edge that makes a mark look drawn, not printed);
+ *
+ * over a warm cream-paper canvas, a soft dashed grid, round-capped chunky
+ * strokes, and a bright six-crayon palette.
+ */
+export const cartoon: ThemePreset = {
+ id: 'cartoon',
+ label: 'Cartoon',
+ description:
+ 'A playful comic house: warm cream paper, a rounded comic typeface, fat dark "sticker" outlines around bright crayon-coloured bars, wedges and dots, rounded corners, chunky round-capped lines, and a soft dashed grid.',
+ guidance: [
+ '- `title` carries the naming in a bold rounded comic block; `subtitle` names the measure in a friendly aside.',
+ '- Annotate the measure with `unit` in `semantic_types`.',
+ '- Colour is a bright crayon set; the key tells 6 series apart.',
+ ].join('\n'),
+ icon: CARTOON_ICON,
+ spec: {
+ id: 'cartoon',
+ label: 'Cartoon',
+ ink: {
+ surface: {
+ source: 'house',
+ canvas: '#fffdf5',
+ plot: '#fffdf5',
+ },
+ text: {
+ primary: '#2e2b28',
+ secondary: '#8a837a',
+ muted: '#b3aa9c',
+ },
+ structure: {
+ grid: '#ece5d6',
+ axis: '#2e2b28',
+ rule: '#2e2b28',
+ // The lollipop stem / dumbbell bridge in a soft pencil grey so
+ // the emoji-ish chunky marks stay the loud part.
+ connector: '#c9c1b2',
+ },
+ series: {
+ // Sky blue reads as the friendly default single.
+ single: '#3aa9ff',
+ // Bright crayon: sky, coral, sunflower, grass, grape, tangerine.
+ categorical: ['#3aa9ff', '#ff5d5d', '#ffc23c', '#4cc76a', '#9b6cff', '#ff8a3d'],
+ categoricalExtended: [
+ '#3aa9ff',
+ '#ff5d5d',
+ '#ffc23c',
+ '#4cc76a',
+ '#9b6cff',
+ '#ff8a3d',
+ '#2ec4c4',
+ '#ff77b7',
+ '#7bd23a',
+ '#ffd84a',
+ '#6c8cff',
+ '#c96a2a',
+ ],
+ // Sequential: a warm cream-to-coral crayon ramp, binned so the
+ // reader can name a bin, not read a wash.
+ sequential: {
+ stops: ['#fff2cc', '#ffd98a', '#ffb14a', '#ff8a3d', '#ff5d5d'],
+ space: 'lab',
+ endpointsAgainstSurface: true,
+ consumption: 'quantize',
+ quantizeCount: 5,
+ },
+ // Diverging: sky to coral, through the warm paper neutral. The
+ // warm end is the high end — a ramp that runs the other way
+ // paints a hot July blue and a cold January red, and no reader
+ // checks the key before believing that.
+ diverging: {
+ stops: ['#3aa9ff', '#8fc9ff', '#f2ead8', '#ffb0a0', '#ff5d5d'],
+ neutral: '#f2ead8',
+ space: 'lab',
+ endpointsAgainstSurface: true,
+ consumption: 'quantize',
+ quantizeCount: 5,
+ },
+ // Signed data: grass up, coral down, a soft pencil grey total.
+ status: {
+ positive: '#4cc76a',
+ negative: '#ff5d5d',
+ neutral: '#b3aa9c',
+ },
+ overflow: '#b3aa9c',
+ selection: {
+ signed: 'status',
+ statusUse: 'anySigned',
+ },
+ },
+ accent: '#ff5d5d',
+ },
+ type: {
+ minSize: 9,
+ // One rounded comic face carries every role: `bodyFamily` falls back
+ // to the headline family, so axis and value labels inherit it.
+ headline: {
+ family: "'Comic Sans MS', 'Comic Neue', 'Chalkboard SE', 'Marker Felt', cursive",
+ size: 'text.400',
+ weight: 'bold',
+ },
+ deck: {
+ size: 'text.200',
+ color: '#8a837a',
+ },
+ axisLabel: {
+ size: 'text.100',
+ },
+ axisTitle: {
+ size: 'text.100',
+ weight: 'bold',
+ color: '#2e2b28',
+ },
+ },
+ structure: {
+ axis: {
+ categorical: {
+ line: 'full',
+ lineWeight: 2.5,
+ ticks: 'omit',
+ labelGap: 7,
+ },
+ measure: {
+ line: 'full',
+ lineWeight: 2.5,
+ ticks: 'omit',
+ labelGap: 7,
+ },
+ },
+ // A soft dashed grid the reader reads values off, only across the
+ // value axis — the category side stays clean.
+ grid: {
+ measure: 'quiet',
+ category: 'omit',
+ style: 'dashed',
+ weight: 1.5,
+ },
+ frame: 'omit',
+ baseline: 'full',
+ },
+ marks: {
+ // Chunky bars with a friendly gap between them.
+ bandFraction: 0.62,
+ // Fat round-capped, round-joined strokes and bouncy curves.
+ strokeWeight: 5,
+ strokeCap: 'round',
+ strokeJoin: 'round',
+ interpolation: 'monotone',
+ // Rounded bar tops and wedge corners — the balloon/gumball tell.
+ cornerRadius: 10,
+ // The sticker edge: a fat dark outline around every filled shape.
+ outline: { presence: 'full', weight: 2.5, source: 'ink' },
+ point: {
+ presence: 'full',
+ fill: 'solid',
+ size: 170,
+ // The dark sticker edge is the identity here; a pale halo would
+ // replace it because Vega-Lite gives a point only one stroke.
+ halo: { presence: 'omit' },
+ },
+ // Wedges swing apart (keeping their dark ring) rather than being cut
+ // by a rule that would paint over the outline.
+ slice: {
+ gap: 5,
+ gapStyle: 'pad',
+ },
+ sizeRange: [120, 2600],
+ },
+ labels: {
+ truncation: 'never',
+ flush: true,
+ angle: 'auto',
+ },
+ legend: {
+ show: 'always',
+ placement: ['top'],
+ direction: 'horizontal',
+ title: 'omit',
+ suppressWhenAxisNames: true,
+ },
+ dataLabels: {
+ show: 'whenTheyFit',
+ placement: 'outsideMark',
+ inkMode: 'fixed',
+ },
+ annotation: {
+ axisTitles: 'whenAmbiguous',
+ unit: 'lastTick',
+ numberFormat: {
+ precision: 'auto',
+ },
+ },
+ layout: {
+ density: 'normal',
+ targetWidth: 300,
+ titleBlock: {
+ anchor: 'start',
+ gap: 'normal',
+ },
+ },
+ compileDefaults: {
+ baseSize: { width: 380, height: 320 },
+ },
+ },
+};
diff --git a/packages/flint-js/src/core/theme/presets/datawrapper.ts b/packages/flint-js/src/core/theme/presets/datawrapper.ts
new file mode 100644
index 00000000..b8697c58
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/datawrapper.ts
@@ -0,0 +1,187 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ThemePreset } from '../types';
+import { DATAWRAPPER_ICON } from './icons';
+
+/**
+ * Datawrapper.
+ *
+ * Measured from hand-authored redesigns, not invented — see the Theme Lab.
+ */
+export const datawrapper: ThemePreset = {
+ id: 'datawrapper',
+ label: "Datawrapper",
+ description: "Embedded web chart: narrow column, plain headline and deck, a rule under the footer.",
+ guidance: [
+ "- `title` and `subtitle` do all the naming; annotate the measure with `unit`.",
+ "- Sized for a narrow column, so it reads tall rather than wide.",
+ "- Colour can tell 5 categories apart.",
+ ].join('\n'),
+ icon: DATAWRAPPER_ICON,
+ spec: {
+ "id": "datawrapper",
+ "label": "Datawrapper",
+ "ink": {
+ "surface": {
+ "source": "host"
+ },
+ "text": {
+ "primary": "#333333",
+ "secondary": "#666666",
+ "muted": "#999999"
+ },
+ "structure": {
+ "grid": "#dcdcdc",
+ "rule": "#dcdcdc",
+ "connector": "#c8c8c8",
+ "axis": "#333333"
+ },
+ "series": {
+ "single": "#18a1cd",
+ "categorical": [
+ "#18a1cd",
+ "#e2a233",
+ "#c04a4a",
+ "#2d8659",
+ "#7e5aa2"
+ ],
+ "categoricalExtended": [
+ "#18a1cd",
+ "#e2a233",
+ "#c04a4a",
+ "#2d8659",
+ "#7e5aa2",
+ "#d97b4f",
+ "#5b8fb0",
+ "#b5546a",
+ "#8c9a3f",
+ "#c98ac0",
+ "#6b8e8a",
+ "#a67c52"
+ ],
+ "sequential": {
+ "stops": [
+ "#dceef6",
+ "#a9d3e6",
+ "#6aabcc",
+ "#2f7fa8",
+ "#0b5c82"
+ ],
+ "space": "lab",
+ "consumption": "quantize",
+ "quantizeCount": 5
+ },
+ "diverging": {
+ "stops": [
+ "#2f7fa8",
+ "#a9d3e6",
+ "#f0ece4",
+ "#e8ac70",
+ "#c04a4a"
+ ],
+ "neutral": "#f0ece4",
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "quantize",
+ "quantizeCount": 5
+ },
+ "selection": {},
+ "overflow": "#b9bcbe"
+ },
+ "accent": "#18a1cd"
+ },
+ "type": {
+ "minSize": 11,
+ "headline": {
+ "family": "'Helvetica Neue', Helvetica, Arial, sans-serif",
+ "size": "text.300",
+ "weight": "bold"
+ },
+ "axisLabel": {
+ "size": "text.200"
+ },
+ "keyLabel": {
+ "size": "text.200"
+ }
+ },
+ "structure": {
+ "axis": {
+ "categorical": {
+ "line": "full",
+ "ticks": "omit",
+ "tickLabels": "sparse"
+ },
+ "measure": {
+ "line": "omit",
+ "ticks": "omit"
+ }
+ },
+ "grid": {
+ "measure": "quiet",
+ "category": "omit",
+ "style": "dashed"
+ },
+ "frame": "omit",
+ "baseline": "quiet"
+ },
+ "marks": {
+ "bandFraction": 0.66,
+ "separator": {
+ "presence": "hairline",
+ "source": "surface",
+ "width": 1.5
+ },
+ "point": {
+ "size": 48
+ },
+ "connector": {
+ "presence": "full",
+ "weight": 1
+ }
+ },
+ "labels": {
+ "truncation": "never"
+ },
+ "legend": {
+ "show": "always",
+ "placement": [
+ "top"
+ ],
+ "direction": "horizontal",
+ "title": "omit"
+ },
+ "dataLabels": {
+ "show": "whenTheyFit",
+ "placement": "outsideMark"
+ },
+ "annotation": {
+ "axisTitles": "omit",
+ "unit": "lastTick",
+ "numberFormat": {
+ "precision": "auto"
+ }
+ },
+ "furniture": [
+ {
+ "kind": "footerRule",
+ "anchor": "bottomLeft",
+ "color": "#dcdcdc",
+ "height": 1
+ }
+ ],
+ "interaction": {
+ "tooltipFormat": "matchKey"
+ },
+ "layout": {
+ "density": "normal",
+ "targetWidth": 300,
+ "titleBlock": {
+ "anchor": "start"
+ }
+ },
+ "compileDefaults": {
+ "baseSize": { "width": 420, "height": 340 }
+ }
+ },
+};
diff --git a/packages/flint-js/src/core/theme/presets/economist.ts b/packages/flint-js/src/core/theme/presets/economist.ts
new file mode 100644
index 00000000..f1634bcd
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/economist.ts
@@ -0,0 +1,217 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ThemePreset } from '../types';
+import { ECONOMIST_ICON } from './icons';
+
+/**
+ * The Economist.
+ *
+ * Measured from hand-authored redesigns, not invented — see the Theme Lab.
+ */
+export const economist: ThemePreset = {
+ id: 'economist',
+ label: "The Economist",
+ description: "Print weekly: compact, flat headline over a deck that names the measure, units repeated down the ruler.",
+ guidance: [
+ "- `subtitle` names the measure, the place and the period — \"% of GDP, 2023\".",
+ "- Annotate each measure with `unit` in `semantic_types`.",
+ "- The key holds 3 colours.",
+ ].join('\n'),
+ icon: ECONOMIST_ICON,
+ spec: {
+ "id": "economist",
+ "label": "The Economist",
+ "ink": {
+ "surface": {
+ "source": "host"
+ },
+ "text": {
+ "primary": "#121317",
+ "secondary": "#54585a",
+ "muted": "#8b9196"
+ },
+ "structure": {
+ "grid": "#c9d3da",
+ "axis": "#121317",
+ "rule": "#c9d3da",
+ "zero": "#121317"
+ },
+ "series": {
+ "single": "#006ba2",
+ "categorical": [
+ "#006ba2",
+ "#3ebcd2",
+ "#ebb434",
+ "#379a8b",
+ "#9a3d5b",
+ "#a17ba5"
+ ],
+ // Continuous measure: the Economist blue, light to deep.
+ "sequential": {
+ "stops": [
+ "#dcebf2",
+ "#a7ccdd",
+ "#6ba7c6",
+ "#2f88ae",
+ "#006ba2",
+ "#003f5c"
+ ],
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "interpolate"
+ },
+ "diverging": {
+ "stops": [
+ "#006ba2",
+ "#7ba7b8",
+ "#e9e5dc",
+ "#c8967a",
+ "#a1655a"
+ ],
+ "neutral": "#e9e5dc",
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "quantize",
+ "quantizeCount": 5
+ },
+ "status": {
+ "positive": "#006ba2",
+ "negative": "#e3120b",
+ "neutral": "#b8c4cc"
+ },
+ "overflow": "#b0aca1",
+ "selection": {
+ "signed": "status",
+ "statusUse": "anySigned"
+ }
+ },
+ "accent": "#e3120b"
+ },
+ "type": {
+ "minSize": 8,
+ "headline": {
+ "family": "'Helvetica Neue', Helvetica, Arial, sans-serif",
+ "size": "text.300",
+ "weight": "bold"
+ },
+ "deck": {
+ "size": "text.200",
+ "color": "#54585a"
+ },
+ "axisLabel": {
+ "size": "text.100"
+ }
+ },
+ "structure": {
+ "axis": {
+ "categorical": {
+ "line": "full",
+ "ticks": "omit"
+ },
+ "measure": {
+ "line": "omit",
+ "ticks": "omit",
+ "placement": "opposite"
+ }
+ },
+ "grid": {
+ "measure": "quiet",
+ "category": "omit",
+ "style": "solid",
+ "zero": "full"
+ },
+ "frame": "omit",
+ "baseline": "full"
+ },
+ "marks": {
+ "bandFraction": 0.68,
+ "strokeWeight": 1.6,
+ "slice": {
+ "gap": 1.5
+ },
+ "interval": {
+ "fillOpacity": 0.22,
+ "edge": "quiet",
+ "inkSource": "sameAsCentral"
+ },
+ "point": {
+ "size": 66
+ },
+ "sizeRange": [
+ 10,
+ 450
+ ]
+ },
+ "labels": {
+ "truncation": "never",
+ "angle": "auto"
+ },
+ "legend": {
+ "show": "always",
+ "placement": [
+ "seriesEnd",
+ "top"
+ ],
+ "direction": "horizontal",
+ "title": "omit",
+ "maxSwatches": 3
+ },
+ "dataLabels": {
+ "show": "whenTheyFit",
+ "placement": "atMark"
+ },
+ "annotation": {
+ "axisTitles": "omit",
+ "unit": "everyTick"
+ },
+ "furniture": [
+ {
+ // The Economist "red tab" is a chunky rectangle, not a thin
+ // rule: the style guide draws it ~15pt wide × 5pt tall (≈3:1)
+ // on a 160pt chart — about 1/10 of the width. On this ~460px
+ // plot that is ≈44px wide; a ~12px height keeps the 3–4:1 block
+ // proportion so it reads as the masthead tag, not a hairline.
+ "kind": "mastheadTab",
+ "anchor": "topLeft",
+ "color": "#e3120b",
+ "width": 44,
+ "height": 12
+ }
+ ],
+ "layout": {
+ "density": "compact",
+ "titleBlock": {
+ "anchor": "start",
+ "gap": "tight"
+ }
+ },
+ "compileDefaults": {
+ "baseSize": { "width": 460, "height": 300 }
+ },
+ "variants": [
+ {
+ "when": {
+ "markChannel": "area",
+ "isPartToWhole": false
+ },
+ "then": {
+ "structure": {
+ "axis": {
+ "measure": {
+ "placement": "default"
+ }
+ }
+ }
+ },
+ "because": "Right-hand measure axis is the house default (ggthemes theme_economist; measured opposite on 3/3 bar charts and the electricity-mix part-to-whole area). The one measured exception is a non-part-to-whole range/area band (seattle-range), which keeps y on the left."
+ }
+ ],
+ "chartDefaults": {
+ "Slope Chart": {
+ "showText": true,
+ "showSeriesInLabel": true
+ }
+ }
+ },
+};
diff --git a/packages/flint-js/src/core/theme/presets/icons.ts b/packages/flint-js/src/core/theme/presets/icons.ts
new file mode 100644
index 00000000..480953d4
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/icons.ts
@@ -0,0 +1,170 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * The houses at 16 pixels.
+ *
+ * A theme picker has to say what a house *looks like* before the reader has
+ * seen a chart in it, and a word cannot: "Economist" and "Datawrapper" are both
+ * blue bars on white to anyone who has not met them. So each icon is a tiny
+ * chart drawn in the house's own decisions — its canvas, the first three of its
+ * categorical set, the weight of its rules — rather than an invented glyph.
+ * Nothing here is a colour the house does not already use.
+ *
+ * They are authored in one file on purpose. An icon set is only legible as a
+ * set: what makes each one recognisable is the thing it does that its
+ * neighbours do not, and that can only be judged side by side. At this size
+ * there is room for exactly one such difference each, so every house gets one
+ * and only one:
+ *
+ * flint no house at all — one flat grey trio, no signature
+ * nyt a black headline bar over the plot, the way its charts lead
+ * economist the red flag, top left
+ * nature a bare black L of axis and thin journal bars, no grid
+ * mckinsey bars laid horizontally, the deck-chart posture
+ * datawrapper horizontal grid ruling read through the bars
+ * powerbi the dark canvas
+ * powerbi-light the same bright series on white, with a faint grid
+ * swiss heavy structural black rules on warm paper
+ * pop process-colour quadrants divided by heavy black ink
+ * cartoon rounded bar tops and a thick soft outline
+ *
+ * Each is a complete SVG document so a caller can put it straight in an ``
+ * or inline it. 16×16 with a half-pixel inset frame: the frame is what lets a
+ * white house read as a *tile* rather than as marks floating on the toolbar.
+ */
+
+/** Shared geometry, so the set lines up when the icons sit next to each other. */
+const BASELINE = 12.5;
+
+function tile(canvas: string, frame: string, body: string, radius = 2): string {
+ return [
+ '',
+ ].join('');
+}
+
+/** Three upright bars on the shared baseline, at the heights the set uses. */
+function bars(
+ colors: [string, string, string],
+ heights: [number, number, number],
+ width = 2.6,
+ radius = 0,
+): string {
+ const x = [3, 6.7, 10.4];
+ return colors
+ .map((fill, i) => {
+ const h = heights[i];
+ const rx = radius ? ` rx="${radius}"` : '';
+ return ``;
+ })
+ .join('');
+}
+
+/** A horizontal rule — a baseline, a gridline, the pale ruling behind bars. */
+function rule(x1: number, y: number, x2: number, stroke: string, width = 1): string {
+ return ``;
+}
+
+/** No house: flint's own defaults, stated plainly so "none" is a visible choice. */
+export const FLINT_ICON = tile(
+ '#ffffff',
+ '#dcdcdc',
+ bars(['#4c78a8', '#f58518', '#e45756'], [6.5, 8.5, 5]) +
+ rule(2.6, BASELINE, 13.4, '#bdbdbd'),
+);
+
+/** A black headline bar, then the plot — the order an NYT chart is read in. */
+export const NYT_ICON = tile(
+ '#ffffff',
+ '#dcdcdc',
+ '' +
+ bars(['#2f6b9a', '#c2352b', '#4a8b6f'], [6, 7.6, 4.6]) +
+ rule(2.6, BASELINE, 13.4, '#121212'),
+);
+
+/** The red flag in the corner, and the house blues under one pale rule. */
+export const ECONOMIST_ICON = tile(
+ '#ffffff',
+ '#dcdcdc',
+ '' +
+ bars(['#006ba2', '#3ebcd2', '#ebb434'], [6, 7.6, 4.6]) +
+ rule(2.6, BASELINE, 13.4, '#121317'),
+);
+
+/** A bare black L and thin bars: a journal figure, no grid, no ornament. */
+export const NATURE_ICON = tile(
+ '#ffffff',
+ '#dcdcdc',
+ bars(['#0072b2', '#e69f00', '#009e73'], [6.2, 8, 4.6], 2) +
+ ``,
+);
+
+/** Bars laid on their side against a single spine — the deck-chart posture. */
+export const MCKINSEY_ICON = tile(
+ '#ffffff',
+ '#dcdcdc',
+ '' +
+ '' +
+ '' +
+ '',
+);
+
+/** Grid ruling read straight through the bars, the way its charts are gridded. */
+export const DATAWRAPPER_ICON = tile(
+ '#ffffff',
+ '#dcdcdc',
+ rule(2.6, 5.5, 13.4, '#b3b3b3') +
+ rule(2.6, 8, 13.4, '#b3b3b3') +
+ rule(2.6, 10.5, 13.4, '#b3b3b3') +
+ bars(['#18a1cd', '#e2a233', '#c04a4a'], [6.5, 8.5, 5], 2.2) +
+ rule(2.6, BASELINE, 13.4, '#333333'),
+);
+
+/** The dark canvas, which is the whole point of the house. */
+export const POWERBI_ICON = tile(
+ '#1b1a19',
+ '#3b3a39',
+ rule(2.6, 7.6, 13.4, '#3b3a39') +
+ bars(['#118dff', '#e66c37', '#3bd1c7'], [6.5, 8.5, 5]) +
+ rule(2.6, BASELINE, 13.4, '#3b3a39'),
+);
+
+/** The same series, on white — the pair reads as one house in two surfaces. */
+export const POWERBI_LIGHT_ICON = tile(
+ '#ffffff',
+ '#d2d0ce',
+ rule(2.6, 7.6, 13.4, '#dcdcdc') +
+ bars(['#118dff', '#12239e', '#e66c37'], [6.5, 8.5, 5]) +
+ rule(2.6, BASELINE, 13.4, '#d2d0ce'),
+);
+
+/** Warm paper, square marks, and axes drawn as structure rather than hinted. */
+export const SWISS_ICON = tile(
+ '#f4f1ea',
+ '#d9d5cc',
+ bars(['#e2231a', '#1a1a1a', '#0067a5'], [6.2, 8.2, 4.8]) +
+ ``,
+ 0,
+);
+
+/** Process-colour blocks and heavy black divisions: Swiss turned up to eleven. */
+export const POP_ICON = tile(
+ '#fff200',
+ '#111111',
+ '' +
+ '' +
+ '' +
+ '',
+);
+
+/** Rounded tops and a thick soft rule: the drawn-by-hand register. */
+export const CARTOON_ICON = tile(
+ '#fffdf5',
+ '#ece5d6',
+ bars(['#3aa9ff', '#ff5d5d', '#ffc23c'], [6.4, 8.4, 5], 2.8, 1.3) +
+ rule(2.6, BASELINE, 13.4, '#2e2b28', 1.7),
+ 3.5,
+);
diff --git a/packages/flint-js/src/core/theme/presets/mckinsey.ts b/packages/flint-js/src/core/theme/presets/mckinsey.ts
new file mode 100644
index 00000000..b6df40e7
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/mckinsey.ts
@@ -0,0 +1,215 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ThemePreset } from '../types';
+import { MCKINSEY_ICON } from './icons';
+
+/**
+ * McKinsey.
+ *
+ * Measured from hand-authored redesigns, not invented — see the Theme Lab.
+ */
+export const mckinsey: ThemePreset = {
+ id: 'mckinsey',
+ label: "McKinsey",
+ description: "Consulting deck: wide bands, every value printed in a column, a headline that states the takeaway.",
+ guidance: [
+ "- `title` states the takeaway; `subtitle` names the measure and unit.",
+ "- Colour can tell 5 categories apart.",
+ ].join('\n'),
+ icon: MCKINSEY_ICON,
+ spec: {
+ "id": "mckinsey",
+ "label": "McKinsey",
+ "ink": {
+ "surface": {
+ "source": "host"
+ },
+ "text": {
+ "primary": "#051c2c",
+ "secondary": "#5a6872",
+ "muted": "#8a969d"
+ },
+ "structure": {
+ "axis": "#051c2c",
+ "rule": "#d3dce1",
+ // A lollipop stem borrows the rule where no connector ink is
+ // named, but McKinsey's rule is a near-white gridline (#d3dce1)
+ // — far too faint for a stem that must carry the eye up to the
+ // dot. Their exhibits draw the stem a solid medium-light grey,
+ // clearly present yet subordinate to the near-black dot. So the
+ // connector states its own, more definite ink.
+ "connector": "#a7b1bc"
+ },
+ "series": {
+ // McKinsey's 2020 "Deep Blue" (#051c2c) is deliberately
+ // *almost black* — the firm's own brand refresh describes it
+ // as near-black to project authority against white, and real
+ // exhibits/reports use it as the primary data-bar colour.
+ // So a lone series reads near-black by design; electric blue
+ // (#2251ff) is the house's highlight, not its default. Kept
+ // authentic — do not "fix" it to a lighter blue.
+ "single": "#051c2c",
+ "categorical": [
+ "#051c2c",
+ "#2251ff",
+ "#00a9f4",
+ "#00cfb4",
+ "#8c9ba5"
+ ],
+ // The house is blue at heart, so the extended set does not
+ // reach for a rainbow — it walks the cool wheel the way
+ // McKinsey's own decks do: blue → cyan → teal, then a
+ // restrained turn into violet before the slate. The core five
+ // stay a prefix so a chart's colours don't reshuffle as it
+ // grows past the handful the identity is built on.
+ "categoricalExtended": [
+ "#051c2c",
+ "#2251ff",
+ "#00a9f4",
+ "#00cfb4",
+ "#8c9ba5",
+ "#7c5cff",
+ "#0e7c8b",
+ "#6fb7e8",
+ "#b39ddb",
+ "#3d4f66"
+ ],
+ "sequential": {
+ "stops": [
+ "#eef3f8",
+ "#cfdcea",
+ "#9db8d2",
+ "#5b82ab",
+ "#051c2c"
+ ],
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ // Stated light-to-dark. One ramp, two consumptions: a
+ // part-to-whole pie samples it in reverse, a heat map
+ // interpolates it.
+ "consumption": "interpolate"
+ },
+ // A signed measure crosses zero, and a single-hue ramp cannot
+ // say which side of it a value sits on — dark reads as "more",
+ // not "positive". The house is otherwise all blue, so the one
+ // place it must reach for a second hue is here: cool blue below
+ // zero, a restrained warm above, the light surface tint at the
+ // break. Cool-below / warm-above matches the other houses.
+ "diverging": {
+ "stops": [
+ "#2251ff",
+ "#9db8d2",
+ "#eef3f8",
+ "#d98f6a",
+ "#b4472e"
+ ],
+ "neutral": "#eef3f8",
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "interpolate"
+ },
+ "selection": {
+ "partToWhole": "categorical",
+ "signed": "diverging"
+ },
+ "overflow": "#b6bfc7"
+ },
+ "accent": "#2251ff"
+ },
+ "type": {
+ "minSize": 9,
+ "headline": {
+ "family": "'Helvetica Neue', Helvetica, Arial, sans-serif",
+ "size": "text.300",
+ "weight": "bold"
+ },
+ "axisLabel": {
+ "size": "text.200"
+ },
+ "valueLabel": {
+ "size": "text.200",
+ "weight": "semibold",
+ "color": "#051c2c"
+ }
+ },
+ "structure": {
+ "axis": {
+ "categorical": {
+ "line": "omit",
+ "ticks": "omit"
+ },
+ "measure": {
+ "line": "omit",
+ "ticks": "omit",
+ "suppressWhenValuesPrinted": true
+ }
+ },
+ "grid": {
+ "measure": "omit",
+ "category": "omit"
+ },
+ "frame": "omit",
+ "baseline": "full"
+ },
+ "marks": {
+ "bandFraction": 0.6,
+ "strokeWeight": 2,
+ "connector": {
+ "presence": "full",
+ "weight": 0.8,
+ "spanWeight": 3
+ },
+ "point": {
+ "size": 72
+ },
+ "separator": {
+ "presence": "hairline",
+ "source": "surface",
+ "width": 0.6
+ },
+ "slice": {
+ "gap": 1
+ }
+ },
+ "labels": {
+ "truncation": "never",
+ "angle": "horizontal"
+ },
+ "legend": {
+ "show": "always",
+ "placement": [
+ "seriesEnd",
+ "inline",
+ "top"
+ ],
+ "direction": "horizontal",
+ "title": "omit",
+ "suppressWhenValuesPrinted": true
+ },
+ "dataLabels": {
+ "show": "always",
+ "placement": "column",
+ "inkMode": "contrastWithMark"
+ },
+ "annotation": {
+ "axisTitles": "omit",
+ "numberFormat": {
+ "precision": "integer",
+ "thousands": "separator"
+ }
+ },
+ "layout": {
+ "density": "airy",
+ "titleBlock": {
+ "anchor": "start",
+ "gap": "loose",
+ "deckGap": "loose"
+ },
+ "bandStep": 80
+ },
+ "compileDefaults": {
+ "baseSize": { "width": 440, "height": 300 }
+ }
+ },
+};
diff --git a/packages/flint-js/src/core/theme/presets/nature.ts b/packages/flint-js/src/core/theme/presets/nature.ts
new file mode 100644
index 00000000..47827238
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/nature.ts
@@ -0,0 +1,233 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ThemePreset } from '../types';
+import { NATURE_ICON } from './icons';
+
+/**
+ * Nature.
+ *
+ * Measured from hand-authored redesigns, not invented — see the Theme Lab.
+ */
+export const nature: ThemePreset = {
+ id: 'nature',
+ label: "Nature",
+ description: "Journal figure: small panel, axis titles kept with units, statistics printed beside the fit.",
+ guidance: [
+ "- Annotate every measure with `unit` in `semantic_types`; `subtitle` names the sample, not the unit.",
+ "- Colour can tell 6 categories apart; past that they share a grey.",
+ ].join('\n'),
+ icon: NATURE_ICON,
+ spec: {
+ "id": "nature",
+ "label": "Nature",
+ "ink": {
+ "surface": {
+ "source": "host"
+ },
+ "text": {
+ "primary": "#000000",
+ "secondary": "#000000"
+ },
+ "structure": {
+ "axis": "#000000",
+ "grid": "#00000000",
+ "frame": "#000000"
+ },
+ "series": {
+ "single": "#0072b2",
+ "categorical": [
+ "#0072b2",
+ "#e69f00",
+ "#009e73",
+ "#cc79a7",
+ "#56b4e9",
+ "#d55e00"
+ ],
+ "categoricalExtended": [
+ "#0072b2",
+ "#e69f00",
+ "#009e73",
+ "#cc79a7",
+ "#56b4e9",
+ "#d55e00",
+ "#f0e442",
+ "#332288",
+ "#117733",
+ "#882255",
+ "#88ccee",
+ "#999933"
+ ],
+ // Continuous measure: the house blue (Wong), light to deep.
+ "sequential": {
+ "stops": [
+ "#e6f0f7",
+ "#b3d3e8",
+ "#79b0d5",
+ "#3a8fc4",
+ "#0072b2",
+ "#00436a"
+ ],
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "interpolate"
+ },
+ "diverging": {
+ "stops": [
+ "#0072b2",
+ "#83b9db",
+ "#ffffff",
+ "#eba06a",
+ "#d55e00"
+ ],
+ "neutral": "#ffffff",
+ "space": "lab",
+ "endpointsAgainstSurface": false,
+ "consumption": "interpolate"
+ },
+ "overflow": "#999999",
+ "selection": {
+ "partToWhole": "categorical"
+ }
+ },
+ "accent": "#000000"
+ },
+ "type": {
+ "minSize": 8.5,
+ "headline": {
+ "family": "Arial, Helvetica, sans-serif",
+ "size": "text.200",
+ "weight": "bold"
+ },
+ "deck": {
+ "size": "text.100",
+ "style": "italic",
+ "case": "asIs"
+ },
+ "axisLabel": {
+ "family": "Arial, Helvetica, sans-serif",
+ "size": "text.100"
+ },
+ "axisTitle": {
+ "family": "Arial, Helvetica, sans-serif",
+ "size": "text.100"
+ }
+ },
+ "structure": {
+ "axis": {
+ "categorical": {
+ "line": "full",
+ "ticks": "full",
+ "tickLength": "long",
+ "tickDirection": "outward"
+ },
+ "measure": {
+ "line": "full",
+ "ticks": "full",
+ "tickLength": "long",
+ "tickDirection": "outward"
+ }
+ },
+ "grid": {
+ "measure": "omit",
+ "category": "omit"
+ },
+ "frame": "omit",
+ "baseline": "quiet"
+ },
+ "marks": {
+ "bandFraction": 0.55,
+ "strokeWeight": 1.2,
+ "separator": {
+ "presence": "hairline",
+ "source": "surface",
+ "width": 0.5
+ },
+ "slice": {
+ "gap": 1.5
+ },
+ "point": {
+ "presence": "full",
+ "size": 45,
+ "fill": "solid",
+ "halo": {
+ "presence": "hairline",
+ "width": 0.6
+ }
+ },
+ "interval": {
+ "fillOpacity": 0.25,
+ "edge": "omit",
+ "inkSource": "sameAsCentral"
+ },
+ "summary": {
+ "fill": "omit",
+ "outline": "full",
+ "centralRule": "emphasised",
+ "widthFraction": 0.4
+ },
+ "observations": {
+ "expose": "always",
+ "maxRows": 400
+ },
+ "zOrder": "summaryUnderData",
+ "redundantEncoding": "always",
+ "redundantChannels": [
+ "shape"
+ ]
+ },
+ "labels": {
+ "truncation": "never"
+ },
+ "legend": {
+ "show": "always",
+ "placement": [
+ "right",
+ "inside"
+ ],
+ "title": "whenAmbiguous",
+ "suppressWhenAxisNames": true
+ },
+ "dataLabels": {
+ "show": "whenTheyFit",
+ "placement": "outsideMark"
+ },
+ "annotation": {
+ "axisTitles": "always",
+ "axisTitlePlacement": "rotated",
+ "unitsInAxisTitle": true,
+ "statistics": {
+ "show": [
+ "n",
+ "r2",
+ "slope"
+ ],
+ "placement": "panel"
+ }
+ },
+ "layout": {
+ "density": "compact",
+ "targetWidth": 252,
+ "titleBlock": {
+ "anchor": "middle",
+ "position": "bottom",
+ "gap": "tight",
+ "deckGap": "tight"
+ },
+ "bandStep": 46
+ },
+ "compileDefaults": {
+ "baseSize": { "width": 300, "height": 250 }
+ },
+ "chartDefaults": {
+ "Boxplot": {
+ "showPoints": true
+ },
+ "Violin Plot": {
+ "showPoints": true,
+ "showMedian": true,
+ "showContour": true
+ }
+ }
+ },
+};
diff --git a/packages/flint-js/src/core/theme/presets/nyt.ts b/packages/flint-js/src/core/theme/presets/nyt.ts
new file mode 100644
index 00000000..a759871f
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/nyt.ts
@@ -0,0 +1,216 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ThemePreset } from '../types';
+import { NYT_ICON } from './icons';
+
+/**
+ * New York Times.
+ *
+ * Measured from hand-authored redesigns, not invented — see the Theme Lab.
+ */
+export const nyt: ThemePreset = {
+ id: 'nyt',
+ label: "New York Times",
+ description: "Newsroom graphics: a headline that states the finding, values printed on the marks, series named at their own ends.",
+ guidance: [
+ "- `title` is the finding, in a sentence; `subtitle` names the measure, the population and the unit.",
+ "- Colour can tell 5 categories apart; past that they share a grey.",
+ ].join('\n'),
+ icon: NYT_ICON,
+ spec: {
+ "id": "nyt",
+ "label": "New York Times",
+ "ink": {
+ "surface": {
+ "source": "host"
+ },
+ "text": {
+ "primary": "#121212",
+ "secondary": "#6b6b6b",
+ "muted": "#8a8a8a",
+ "inverse": "#ffffff"
+ },
+ "structure": {
+ "grid": "#e4e4e4",
+ "axis": "#121212",
+ "rule": "#121212"
+ },
+ "series": {
+ "single": "#2f6b9a",
+ "categorical": [
+ "#2f6b9a",
+ "#c2352b",
+ "#4a8b6f",
+ "#7f6a9e",
+ "#d9a441"
+ ],
+ "categoricalExtended": [
+ "#2f6b9a",
+ "#c2352b",
+ "#4a8b6f",
+ "#7f6a9e",
+ "#d9a441",
+ "#e27ea6",
+ "#3fae9e",
+ "#9ca13a",
+ "#8c6d31",
+ "#6b8fb3",
+ "#d07b3a",
+ "#b5546a"
+ ],
+ // Continuous measure: the house blue, light tint to deep, so
+ // a heat map or choropleth reads as one hue growing, not a set.
+ "sequential": {
+ "stops": [
+ "#eef4f8",
+ "#c2d8e7",
+ "#8bb0cf",
+ "#5187b3",
+ "#2f6b9a",
+ "#1c4363"
+ ],
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "interpolate"
+ },
+ "diverging": {
+ "stops": [
+ "#2f6b9a",
+ "#8fb4cc",
+ "#efece5",
+ "#dd9a86",
+ "#c2352b"
+ ],
+ "neutral": "#efece5",
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "interpolate"
+ },
+ "overflow": "#9e9e9e",
+ "status": {
+ "positive": "#2f6b9a",
+ "negative": "#c2352b",
+ "neutral": "#9e9e9e"
+ },
+ "selection": {
+ "signed": "status",
+ "statusUse": "anySigned"
+ }
+ },
+ "accent": "#c2352b"
+ },
+ "type": {
+ "minSize": 8,
+ "headline": {
+ "family": "Georgia, serif",
+ "size": "text.400",
+ "weight": "bold",
+ "color": "#121212"
+ },
+ "deck": {
+ "family": "Georgia, serif",
+ "size": "text.200",
+ "color": "#6b6b6b"
+ },
+ "axisLabel": {
+ "family": "Helvetica, Arial, sans-serif",
+ "size": "text.100"
+ },
+ "valueLabel": {
+ "family": "Helvetica, Arial, sans-serif",
+ "size": "text.100",
+ "weight": "bold"
+ }
+ },
+ "structure": {
+ "axis": {
+ "categorical": {
+ "line": "full",
+ "ticks": "omit",
+ "tickLabels": "sparse"
+ },
+ "measure": {
+ "line": "omit",
+ "ticks": "omit",
+ "tickDensity": "sparse",
+ "suppressWhenValuesPrinted": true
+ }
+ },
+ "grid": {
+ "measure": "quiet",
+ "category": "omit",
+ "style": "solid",
+ "zero": "full"
+ },
+ "frame": "omit",
+ "baseline": "full"
+ },
+ "marks": {
+ "bandFraction": 0.72,
+ "strokeWeight": 2.4,
+ "strokeCap": "round",
+ "strokeJoin": "round",
+ "slice": {
+ "gap": 1.5
+ },
+ "tile": {
+ "gap": 1
+ },
+ "point": {
+ "size": 58
+ },
+ "zOrder": "summaryOverData",
+ "redundantEncoding": "whenNeeded",
+ "redundantChannels": [
+ "dash"
+ ]
+ },
+ "labels": {
+ "truncation": "never",
+ "flush": true
+ },
+ "legend": {
+ "show": "always",
+ "placement": [
+ "seriesEnd",
+ "top"
+ ],
+ "title": "omit",
+ "suppressWhenValuesPrinted": false
+ },
+ "dataLabels": {
+ "show": "always",
+ "placement": "atMark",
+ "inkMode": "contrastWithMark"
+ },
+ "annotation": {
+ "axisTitles": "omit",
+ "axisTitlePlacement": "flatAboveAxis",
+ "unit": "lastTick",
+ "pointEmphasis": "endpoints",
+ "numberFormat": {
+ "precision": "auto",
+ "thousands": "suffix"
+ }
+ },
+ "layout": {
+ "density": "normal",
+ "titleBlock": {
+ "anchor": "start",
+ "deckGap": "tight"
+ }
+ },
+ "compileDefaults": {
+ "baseSize": { "width": 380, "height": 340 }
+ },
+ "chartDefaults": {
+ "Line Chart": {
+ "showPoints": true
+ },
+ "Bump Chart": {
+ "interpolate": "linear"
+ }
+ }
+ },
+};
diff --git a/packages/flint-js/src/core/theme/presets/pop.ts b/packages/flint-js/src/core/theme/presets/pop.ts
new file mode 100644
index 00000000..e29dcc29
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/pop.ts
@@ -0,0 +1,85 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ThemePreset } from '../types';
+import { POP_ICON } from './icons';
+
+/** A loud pop-art remix that demonstrates how little a derived house needs to say. */
+export const pop: ThemePreset = {
+ id: 'pop',
+ label: 'Pop',
+ description:
+ 'A pop-art remix of Swiss: electric process colours, heavy black structure, oversized marks, and punchy display type.',
+ guidance: [
+ '- Use a short title that can carry the poster-like display treatment.',
+ '- Strong categorical or binned quantitative data makes best use of the 6-colour process key.',
+ '- Keep annotations concise; the heavy structure and high-contrast marks already speak loudly.',
+ ].join('\n'),
+ icon: POP_ICON,
+ spec: {
+ extends: 'swiss',
+ id: 'pop',
+ label: 'Pop',
+ ink: {
+ surface: { source: 'house', canvas: '#fff200', plot: '#fff200' },
+ text: { primary: '#111111', secondary: '#5f0047', muted: '#8c0068', inverse: '#fff200' },
+ structure: { axis: '#111111', grid: '#111111', rule: '#111111', connector: '#111111' },
+ series: {
+ single: '#ff1493',
+ categorical: ['#ff1493', '#00d9ff', '#ff5a1f', '#7a3cff', '#00c853', '#111111'],
+ sequential: {
+ stops: ['#00d9ff', '#7a3cff', '#ff1493', '#ff5a1f', '#fff200'],
+ space: 'rgb',
+ endpointsAgainstSurface: true,
+ consumption: 'quantize',
+ quantizeCount: 5,
+ },
+ diverging: {
+ stops: ['#00d9ff', '#7a3cff', '#fff200', '#ff5a1f', '#ff1493'],
+ neutral: '#fff200',
+ space: 'rgb',
+ endpointsAgainstSurface: true,
+ consumption: 'quantize',
+ quantizeCount: 5,
+ },
+ status: { positive: '#00c853', negative: '#ff1493', neutral: '#111111' },
+ overflow: '#111111',
+ },
+ accent: '#ff1493',
+ },
+ type: {
+ minSize: 10,
+ headline: {
+ family: "'Arial Black', 'Helvetica Neue', Arial, sans-serif",
+ size: 'text.500',
+ weight: 'bold',
+ case: 'upper',
+ },
+ deck: { size: 'text.200', weight: 'bold', color: '#5f0047' },
+ axisLabel: { family: "'Arial Black', Arial, sans-serif", size: 'text.100' },
+ axisTitle: { family: "'Arial Black', Arial, sans-serif", size: 'text.100', weight: 'bold' },
+ valueLabel: { family: "'Arial Black', Arial, sans-serif", weight: 'bold' },
+ },
+ structure: {
+ axis: {
+ categorical: { line: 'emphasised', lineWeight: 3, ticks: 'omit' },
+ measure: { line: 'emphasised', lineWeight: 3, ticks: 'full', tickLength: 'long' },
+ },
+ grid: { measure: 'quiet', category: 'hairline', style: 'solid', weight: 1, zero: 'emphasised' },
+ baseline: 'emphasised',
+ },
+ marks: {
+ bandFraction: 0.84,
+ strokeWeight: 6,
+ strokeCap: 'square',
+ strokeJoin: 'miter',
+ fillOpacity: 1,
+ outline: { presence: 'emphasised', weight: 3, source: 'ink' },
+ tile: { gap: 1, source: 'structure' },
+ point: { presence: 'full', size: 180, fill: 'solid', halo: { presence: 'omit' } },
+ separator: { presence: 'emphasised', width: 3, source: 'structure' },
+ },
+ dataLabels: { show: 'whenTheyFit', placement: 'atMark', inkMode: 'contrastWithMark' },
+ layout: { density: 'normal', titleBlock: { anchor: 'start', gap: 'tight' } },
+ },
+};
\ No newline at end of file
diff --git a/packages/flint-js/src/core/theme/presets/powerbi-light.ts b/packages/flint-js/src/core/theme/presets/powerbi-light.ts
new file mode 100644
index 00000000..16de90e3
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/powerbi-light.ts
@@ -0,0 +1,236 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ThemePreset } from '../types';
+import { POWERBI_LIGHT_ICON } from './icons';
+
+/**
+ * Power BI — light.
+ *
+ * The default polished Power BI report look: a white tile, Segoe UI, the
+ * standard #118dff-led data palette, hairline light-grey gridlines, legend to
+ * the right, the latest point emphasised. The dark `powerbi` house flipped to
+ * a light surface — same furniture, same proportions, different ink — so a
+ * reader recognises the same product in either mode.
+ */
+export const powerbiLight: ThemePreset = {
+ id: 'powerbi-light',
+ label: "Power BI (light)",
+ description: "Light dashboard tile: white canvas, Segoe UI, hairline grid, legend to the right.",
+ guidance: [
+ "- Leave `title` out where the tile sits under its own caption — the axis titles come back to name the measure.",
+ "- Colour can tell 6 categories apart.",
+ ].join('\n'),
+ icon: POWERBI_LIGHT_ICON,
+ spec: {
+ "id": "powerbi-light",
+ "label": "Power BI (light)",
+ "ink": {
+ "surface": {
+ "source": "house",
+ "canvas": "#ffffff",
+ "plot": "#ffffff",
+ "panel": "#faf9f8"
+ },
+ "text": {
+ "primary": "#252423",
+ "secondary": "#605e5c",
+ "muted": "#a19f9d",
+ "inverse": "#ffffff"
+ },
+ "structure": {
+ "grid": "#ededed",
+ "axis": "#d2d0ce",
+ "rule": "#d2d0ce",
+ "connector": "#8a8886"
+ },
+ "series": {
+ "single": "#118dff",
+ "categorical": [
+ "#118dff",
+ "#12239e",
+ "#e66c37",
+ "#6b007b",
+ "#e044a7",
+ "#744ec2"
+ ],
+ "categoricalExtended": [
+ "#118dff",
+ "#12239e",
+ "#e66c37",
+ "#6b007b",
+ "#e044a7",
+ "#744ec2",
+ "#d9b300",
+ "#d64550",
+ "#197278",
+ "#5c2e91",
+ "#ff9d3b",
+ "#4a9c2d"
+ ],
+ // Continuous measure: Power BI azure, light to deep.
+ "sequential": {
+ "stops": [
+ "#e5f1ff",
+ "#b3d7ff",
+ "#7bbcff",
+ "#3f9dff",
+ "#118dff",
+ "#0a5cb5"
+ ],
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "interpolate"
+ },
+ "diverging": {
+ "stops": [
+ "#118dff",
+ "#7bb8f5",
+ "#e1dfdd",
+ "#e59866",
+ "#d64550"
+ ],
+ "neutral": "#e1dfdd",
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "interpolate"
+ },
+ "status": {
+ "positive": "#107c10",
+ "negative": "#d13438",
+ "neutral": "#a19f9d"
+ },
+ "selection": {
+ "signed": "diverging",
+ "statusUse": "thresholdOnly",
+ "redundantWithFacet": "single"
+ },
+ "overflow": "#bcbcbc"
+ },
+ "accent": "#118dff"
+ },
+ "type": {
+ "minSize": 8,
+ "headline": {
+ "family": "'Segoe UI', system-ui, sans-serif",
+ "size": "text.200",
+ "weight": "semibold",
+ "color": "#252423"
+ },
+ "display": {
+ "family": "'Segoe UI', system-ui, sans-serif",
+ "size": "text.hero900",
+ "weight": "semibold"
+ },
+ "axisLabel": {
+ "family": "'Segoe UI', system-ui, sans-serif",
+ "size": "text.100",
+ "color": "#605e5c"
+ },
+ "keyLabel": {
+ "size": "text.100",
+ "color": "#605e5c"
+ }
+ },
+ "structure": {
+ "axis": {
+ "categorical": {
+ "line": "omit",
+ "ticks": "omit",
+ "tickLabels": "sparse"
+ },
+ "measure": {
+ "line": "omit",
+ "ticks": "omit",
+ "tickDensity": "sparse"
+ }
+ },
+ "grid": {
+ "measure": "quiet",
+ "category": "omit",
+ "style": "solid"
+ },
+ "frame": "omit",
+ "baseline": "quiet"
+ },
+ "marks": {
+ "strokeWeight": 2.2,
+ "strokeCap": "square",
+ "minSize": 1.5,
+ "point": {
+ "size": 62
+ },
+ "separator": {
+ "presence": "hairline",
+ "source": "surface",
+ "width": 1
+ },
+ "slice": {
+ "gap": 1.5
+ },
+ "connector": {
+ "presence": "full",
+ "weight": 1.5,
+ "spanWeight": 2
+ },
+ "trailingFill": {
+ "presence": "quiet",
+ "opacity": 0.18
+ },
+ "reference": {
+ "presence": "full",
+ "style": "tick",
+ "label": true,
+ "weight": 2
+ }
+ },
+ "labels": {
+ "truncation": "never"
+ },
+ "legend": {
+ "show": "always",
+ "placement": [
+ "right",
+ "bottom"
+ ],
+ "title": "omit",
+ "gradientLength": 90,
+ "suppressWhenValuesPrinted": false
+ },
+ "dataLabels": {
+ "show": "whenTheyFit",
+ "placement": "atMark",
+ "inkMode": "contrastWithMark"
+ },
+ "annotation": {
+ "axisTitles": "omit",
+ "unit": "everyTick",
+ "pointEmphasis": "latest",
+ "numberFormat": {
+ "precision": "auto"
+ }
+ },
+ "facets": {
+ "header": {
+ "presence": "full",
+ "style": "flushLabel",
+ "fieldTitle": "omit"
+ },
+ "panelFrame": "omit",
+ "axisRepetition": "edgeOnly",
+ "preferredColumns": 4,
+ "sharedScale": "whenComparable"
+ },
+ "layout": {
+ "density": "compact",
+ "titleBlock": {
+ "anchor": "start",
+ "gap": "tight",
+ "deckGap": "tight"
+ }
+ },
+ "compileDefaults": {
+ "baseSize": { "width": 480, "height": 280 }
+ }
+ },
+};
diff --git a/packages/flint-js/src/core/theme/presets/powerbi.ts b/packages/flint-js/src/core/theme/presets/powerbi.ts
new file mode 100644
index 00000000..ecb52865
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/powerbi.ts
@@ -0,0 +1,239 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ThemePreset } from '../types';
+import { POWERBI_ICON } from './icons';
+
+/**
+ * Power BI.
+ *
+ * Measured from hand-authored redesigns, not invented — see the Theme Lab.
+ */
+export const powerbi: ThemePreset = {
+ id: 'powerbi',
+ label: "Power BI",
+ description: "Dashboard tile: compact, legend to the right, the latest point emphasised.",
+ guidance: [
+ "- Leave `title` out where the tile sits under its own caption — the axis titles come back to name the measure.",
+ "- Colour can tell 6 categories apart.",
+ ].join('\n'),
+ icon: POWERBI_ICON,
+ spec: {
+ "id": "powerbi",
+ "label": "Power BI",
+ "ink": {
+ "surface": {
+ "source": "house",
+ "canvas": "#1b1a19",
+ "plot": "#1b1a19",
+ "panel": "#252423"
+ },
+ "text": {
+ "primary": "#f3f2f1",
+ "secondary": "#c8c6c4",
+ "muted": "#a19f9d",
+ "inverse": "#1b1a19"
+ },
+ "structure": {
+ "grid": "#3b3a39",
+ "axis": "#3b3a39",
+ "rule": "#3b3a39",
+ "connector": "#797775"
+ },
+ "series": {
+ "single": "#118dff",
+ // Power BI themes name dataColors explicitly; the classic
+ // defaults are for a light report canvas. This dark set keeps
+ // the product's azure/orange/magenta character while every
+ // swatch clears 3:1 against both the plot and panel.
+ "categorical": [
+ "#118dff",
+ "#e66c37",
+ "#3bd1c7",
+ "#e044a7",
+ "#d9b300",
+ "#8764b8"
+ ],
+ "categoricalExtended": [
+ "#118dff",
+ "#e66c37",
+ "#3bd1c7",
+ "#e044a7",
+ "#d9b300",
+ "#8764b8",
+ "#d64550",
+ "#4a9c2d",
+ "#6677d9",
+ "#b146c2",
+ "#ff9d3b",
+ "#25797f"
+ ],
+ // Continuous measure on a dark canvas: dim blue at the low end
+ // (never the background) rising to bright azure, so "more"
+ // reads as brighter — a light-to-dark ramp would sink the high
+ // values into the near-black plot.
+ "sequential": {
+ "stops": [
+ "#123049",
+ "#0f4c86",
+ "#1170c9",
+ "#3f9dff",
+ "#7bbcff",
+ "#c9e3ff"
+ ],
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "interpolate"
+ },
+ "diverging": {
+ "stops": [
+ "#118dff",
+ "#5aa9f0",
+ "#4a4948",
+ "#e08a4a",
+ "#d64550"
+ ],
+ "neutral": "#4a4948",
+ "space": "lab",
+ "endpointsAgainstSurface": true,
+ "consumption": "interpolate"
+ },
+ "status": {
+ "positive": "#22b14c",
+ "negative": "#e66c37",
+ "neutral": "#a19f9d"
+ },
+ "selection": {
+ "signed": "diverging",
+ "statusUse": "thresholdOnly",
+ "redundantWithFacet": "single"
+ },
+ "overflow": "#8a8886"
+ },
+ "accent": "#118dff"
+ },
+ "type": {
+ "minSize": 8,
+ "headline": {
+ "family": "'Segoe UI', system-ui, sans-serif",
+ "size": "text.200",
+ "weight": "semibold",
+ "color": "#f3f2f1"
+ },
+ "display": {
+ "family": "'Segoe UI', system-ui, sans-serif",
+ "size": "text.hero900",
+ "weight": "semibold"
+ },
+ "axisLabel": {
+ "family": "'Segoe UI', system-ui, sans-serif",
+ "size": "text.100",
+ "color": "#c8c6c4"
+ },
+ "keyLabel": {
+ "size": "text.100",
+ "color": "#c8c6c4"
+ }
+ },
+ "structure": {
+ "axis": {
+ "categorical": {
+ "line": "omit",
+ "ticks": "omit",
+ "tickLabels": "sparse"
+ },
+ "measure": {
+ "line": "omit",
+ "ticks": "omit",
+ "tickDensity": "sparse"
+ }
+ },
+ "grid": {
+ "measure": "quiet",
+ "category": "omit",
+ "style": "solid"
+ },
+ "frame": "omit",
+ "baseline": "quiet"
+ },
+ "marks": {
+ "strokeWeight": 2.2,
+ "strokeCap": "square",
+ "minSize": 1.5,
+ "point": {
+ "size": 62
+ },
+ "separator": {
+ "presence": "hairline",
+ "source": "surface",
+ "width": 1
+ },
+ "slice": {
+ "gap": 1.5
+ },
+ "connector": {
+ "presence": "full",
+ "weight": 1.5,
+ "spanWeight": 2
+ },
+ "trailingFill": {
+ "presence": "quiet",
+ "opacity": 0.18
+ },
+ "reference": {
+ "presence": "full",
+ "style": "tick",
+ "label": true,
+ "weight": 2
+ }
+ },
+ "labels": {
+ "truncation": "never"
+ },
+ "legend": {
+ "show": "always",
+ "placement": [
+ "right",
+ "bottom"
+ ],
+ "title": "omit",
+ "gradientLength": 90,
+ "suppressWhenValuesPrinted": false
+ },
+ "dataLabels": {
+ "show": "whenTheyFit",
+ "placement": "atMark",
+ "inkMode": "contrastWithMark"
+ },
+ "annotation": {
+ "axisTitles": "omit",
+ "unit": "everyTick",
+ "pointEmphasis": "latest",
+ "numberFormat": {
+ "precision": "auto"
+ }
+ },
+ "facets": {
+ "header": {
+ "presence": "full",
+ "style": "flushLabel",
+ "fieldTitle": "omit"
+ },
+ "panelFrame": "omit",
+ "axisRepetition": "edgeOnly",
+ "preferredColumns": 4,
+ "sharedScale": "whenComparable"
+ },
+ "layout": {
+ "density": "compact",
+ "titleBlock": {
+ "anchor": "start",
+ "gap": "tight",
+ "deckGap": "tight"
+ }
+ },
+ "compileDefaults": {
+ "baseSize": { "width": 480, "height": 280 }
+ }
+ },
+};
diff --git a/packages/flint-js/src/core/theme/presets/swiss.ts b/packages/flint-js/src/core/theme/presets/swiss.ts
new file mode 100644
index 00000000..01f12182
--- /dev/null
+++ b/packages/flint-js/src/core/theme/presets/swiss.ts
@@ -0,0 +1,206 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ThemePreset } from '../types';
+import { SWISS_ICON } from './icons';
+
+/**
+ * Swiss / International Typographic Style.
+ *
+ * Modelled on the hand-authored mockups in the Swiss lab (see
+ * `site/src/playground/swiss-lab-data.ts`), which were in turn measured against
+ * the poster/diagram canon — swissted.com, Poster House "The Swiss Grid",
+ * Müller-Brockmann's Tonhalle posters, Vignelli's 1972 NYC subway diagram, and
+ * Otl Aicher's 1972 Munich colour system.
+ *
+ * What makes it a different design *language* rather than a palette reskin of
+ * the editorial houses: the modular grid is drawn, not hidden (NYT hides it);
+ * the axes are structural black rules; the headline is a bold flush-left
+ * Helvetica block; and colour is a single saturated signal red against warm
+ * paper, with flat primaries for extra categories. No gradients on the marks,
+ * no rounded corners, no ornament.
+ */
+export const swiss: ThemePreset = {
+ id: 'swiss',
+ label: 'Swiss',
+ description:
+ 'International Typographic Style: warm paper, a visible modular grid, black structural axes, a bold flush-left Helvetica headline over a rule, and a single signal-red accent.',
+ guidance: [
+ '- `title` carries the naming in a bold flush-left block; `subtitle` names the measure and period.',
+ '- Annotate the measure with `unit` in `semantic_types`.',
+ '- Colour is a single signal red; the categorical key tells 5 series apart.',
+ ].join('\n'),
+ icon: SWISS_ICON,
+ spec: {
+ id: 'swiss',
+ label: 'Swiss',
+ ink: {
+ surface: {
+ source: 'house',
+ canvas: '#f4f1ea',
+ plot: '#f4f1ea',
+ },
+ text: {
+ primary: '#1a1a1a',
+ secondary: '#555555',
+ muted: '#8a8a8a',
+ },
+ structure: {
+ grid: '#d9d5cc',
+ axis: '#1a1a1a',
+ rule: '#1a1a1a',
+ connector: '#8a8a8a',
+ },
+ series: {
+ single: '#e2231a',
+ categorical: ['#e2231a', '#1a1a1a', '#0067a5', '#f2b705', '#2a7f4f'],
+ categoricalExtended: [
+ '#e2231a',
+ '#1a1a1a',
+ '#0067a5',
+ '#f2b705',
+ '#2a7f4f',
+ '#e06d1f',
+ '#5b4b8a',
+ '#1f8a8a',
+ '#b5195f',
+ '#8a8a2a',
+ '#5a6b78',
+ '#8a5a2a',
+ ],
+ // Sequential: a single-hue red ramp, binned into steps — a scale
+ // the reader can name a bin off, not a wash.
+ sequential: {
+ stops: ['#fbe3df', '#f4a19a', '#eb6a5f', '#e2231a', '#9e120c'],
+ space: 'lab',
+ endpointsAgainstSurface: true,
+ consumption: 'quantize',
+ quantizeCount: 5,
+ },
+ // Diverging: cobalt to signal red, through the paper neutral.
+ diverging: {
+ stops: ['#0067a5', '#7fb2d6', '#efeae0', '#ef9a90', '#e2231a'],
+ neutral: '#efeae0',
+ space: 'lab',
+ endpointsAgainstSurface: true,
+ consumption: 'quantize',
+ quantizeCount: 5,
+ },
+ // Signed data reads as the two Swiss primaries: cobalt up,
+ // signal red down, a neutral grey for the anchoring total.
+ status: {
+ positive: '#0067a5',
+ negative: '#e2231a',
+ neutral: '#9a9a9a',
+ },
+ overflow: '#9a9a9a',
+ selection: {
+ signed: 'status',
+ statusUse: 'anySigned',
+ },
+ },
+ accent: '#e2231a',
+ },
+ type: {
+ minSize: 9,
+ headline: {
+ family: "'Helvetica Neue', Helvetica, Arial, sans-serif",
+ size: 'text.400',
+ weight: 'bold',
+ },
+ deck: {
+ size: 'text.200',
+ color: '#555555',
+ },
+ axisLabel: {
+ size: 'text.100',
+ },
+ axisTitle: {
+ size: 'text.100',
+ weight: 'bold',
+ color: '#1a1a1a',
+ },
+ },
+ structure: {
+ axis: {
+ categorical: {
+ line: 'full',
+ ticks: 'omit',
+ },
+ measure: {
+ line: 'full',
+ ticks: 'full',
+ tickLength: 'short',
+ tickDirection: 'outward',
+ },
+ },
+ grid: {
+ measure: 'quiet',
+ category: 'omit',
+ style: 'solid',
+ zero: 'full',
+ },
+ frame: 'omit',
+ baseline: 'full',
+ },
+ marks: {
+ bandFraction: 0.7,
+ strokeWeight: 3,
+ strokeCap: 'butt',
+ strokeJoin: 'miter',
+ interpolation: 'linear',
+ point: {
+ presence: 'omit',
+ fill: 'solid',
+ // Small and exact. The grid does the work of placing a
+ // reading here, so the dot only has to mark the spot.
+ size: 52,
+ },
+ separator: {
+ presence: 'hairline',
+ source: 'surface',
+ width: 1.5,
+ },
+ slice: {
+ gap: 2,
+ gapStyle: 'rule',
+ },
+ sizeRange: [12, 400],
+ },
+ labels: {
+ truncation: 'never',
+ flush: true,
+ angle: 'auto',
+ },
+ legend: {
+ show: 'always',
+ placement: ['top'],
+ direction: 'horizontal',
+ title: 'omit',
+ suppressWhenAxisNames: true,
+ },
+ dataLabels: {
+ show: 'whenTheyFit',
+ placement: 'outsideMark',
+ inkMode: 'fixed',
+ },
+ annotation: {
+ axisTitles: 'whenAmbiguous',
+ unit: 'lastTick',
+ numberFormat: {
+ precision: 'auto',
+ },
+ },
+ layout: {
+ density: 'normal',
+ targetWidth: 300,
+ titleBlock: {
+ anchor: 'start',
+ gap: 'normal',
+ },
+ },
+ compileDefaults: {
+ baseSize: { width: 420, height: 320 },
+ },
+ },
+};
diff --git a/packages/flint-js/src/core/theme/types.ts b/packages/flint-js/src/core/theme/types.ts
new file mode 100644
index 00000000..895d453c
--- /dev/null
+++ b/packages/flint-js/src/core/theme/types.ts
@@ -0,0 +1,817 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * ThemeSpec — level 1 (authored) and level 2 (grounded) types.
+ *
+ * See `design-docs/03-themeSpec-abstract-design.md` and
+ * `design-docs/themespec/fields.md` for the argument behind every field.
+ *
+ * The invariant this file encodes:
+ *
+ * LEVEL 1 (`ThemeSpec`) never names a chart type, a positional channel,
+ * a mark type, a field, or a backend property.
+ *
+ * LEVEL 2 (`DesignDecisions`) has bound every role to a concrete part of
+ * *this* chart and resolved every policy against the space actually
+ * available — but is still backend-neutral.
+ *
+ * Level 3 (realization) lives in each backend, e.g. `vegalite/theme.ts`.
+ */
+
+import type { AssembleOptions } from '../types.js';
+
+// ---------------------------------------------------------------------------
+// Level 1 — the authored ThemeSpec
+// ---------------------------------------------------------------------------
+
+/** The presence ordinal. Grounding may step DOWN it, and must report. */
+export type Presence = 'omit' | 'hairline' | 'quiet' | 'full' | 'emphasised';
+
+export type Frequency = 'never' | 'whenNeeded' | 'always';
+
+/** A design-token size reference (`text.100`, `text.hero900`) or a raw px number. */
+export type SizeToken = string | number;
+
+export interface TypeRole {
+ family?: string;
+ size?: SizeToken;
+ weight?: 'regular' | 'medium' | 'semibold' | 'bold';
+ /** Journals set decks and captions in italic; it is a role, not an accident. */
+ style?: 'normal' | 'italic';
+ case?: 'asIs' | 'upper' | 'lower' | 'title';
+ color?: string;
+}
+
+export interface AxisRole {
+ line?: Presence;
+ /** Stroke width of the axis rule in px. Presence still decides whether it is drawn. */
+ lineWeight?: number;
+ ticks?: Presence;
+ tickLength?: 'short' | 'medium' | 'long';
+ tickDirection?: 'outward' | 'inward';
+ /**
+ * Distance from the axis rule to its labels in px. For outward ticks, the
+ * tick occupies the first part of this distance.
+ */
+ labelGap?: number;
+ /** `opposite` = the far side of the plot (top for x, right for y). */
+ placement?: 'default' | 'opposite';
+ tickLabels?: 'all' | 'observed' | 'endpoints' | 'sparse';
+ tickDensity?: 'sparse' | 'normal' | 'dense';
+ /**
+ * Drop the axis entirely when every mark already prints its own value.
+ * The scale is then carried by the numbers, not by a ruler beside them.
+ * Mirrors `legend.suppressWhenValuesPrinted`.
+ */
+ suppressWhenValuesPrinted?: boolean;
+}
+
+/**
+ * Control points of an interpolator — NOT an indexed set.
+ * Length is resolution, not capacity, so `overflow` does not apply.
+ */
+export interface Ramp {
+ stops: string[];
+ /** Diverging only: the ink at the pivot. */
+ neutral?: string;
+ space?: 'rgb' | 'lab' | 'hcl';
+ /** Forbid the ramp endpoints colliding with the canvas. */
+ endpointsAgainstSurface?: boolean;
+ consumption?: 'interpolate' | 'quantize' | 'sampleCategorical';
+ quantizeCount?: number;
+}
+
+export interface ThemeInk {
+ surface?: {
+ source?: 'host' | 'house';
+ canvas?: string;
+ plot?: string;
+ panel?: string;
+ };
+ text?: {
+ primary?: string;
+ secondary?: string;
+ muted?: string;
+ inverse?: string;
+ };
+ /** The ink the presence ordinal scales against. */
+ structure?: {
+ axis?: string;
+ grid?: string;
+ frame?: string;
+ rule?: string;
+ /**
+ * The zero line, when the house makes it more than one gridline among
+ * the rest. The Economist strokes zero in its signature red so every
+ * length is read from a line the eye cannot miss. It borrows `rule`
+ * (then `axis`) where the house says nothing, so a house that draws an
+ * ordinary zero needs to state nothing.
+ */
+ zero?: string;
+ /**
+ * The stem of a lollipop, the bridge of a dumbbell. It borrows `rule`
+ * where the house says nothing, but the two are not the same job: a
+ * gridline is read *through*, so it sits at the bottom of the ordinal,
+ * while a connector is part of the mark and has to hold its shape at
+ * a hairline's width. A house whose gridlines are already pale has no
+ * room left below them, and states this ink instead.
+ */
+ connector?: string;
+ };
+ series?: {
+ single?: string;
+ categorical?: string[];
+ /**
+ * A larger indexed set the house reaches for when a chart has more
+ * series than its core {@link categorical} palette can name, in the
+ * spirit of Tableau's 10→20 step. The core set carries the house's
+ * identity at low cardinality (a handful of well-known inks); the
+ * extended set trades a little of that identity for the capacity to
+ * keep every series distinct up to its length. Grounding picks the
+ * smallest set whose length covers the series count; past the extended
+ * set's length the {@link overflow} ink takes the tail.
+ *
+ * Must contain the core set's inks as a prefix is *not* required — but
+ * ordering the shared hues first keeps a chart's colours stable as it
+ * grows. Unset ⇒ the house has only its core palette.
+ */
+ categoricalExtended?: string[];
+ overflow?: string;
+ sequential?: Ramp;
+ diverging?: Ramp;
+ status?: { positive?: string; negative?: string; neutral?: string };
+ selection?: {
+ partToWhole?: 'categorical' | 'sequentialRamp';
+ signed?: 'categorical' | 'status' | 'diverging' | 'sequential';
+ redundantWithFacet?: 'single' | 'categorical';
+ statusUse?: 'anySigned' | 'thresholdOnly' | 'never';
+ };
+ };
+ accent?: string;
+}
+
+export interface ThemeType {
+ minSize?: number;
+ headline?: TypeRole;
+ deck?: TypeRole;
+ axisLabel?: TypeRole;
+ axisTitle?: TypeRole;
+ valueLabel?: TypeRole;
+ keyLabel?: TypeRole;
+ annotation?: TypeRole;
+ footnote?: TypeRole;
+ /** The KPI big-number role — data, not chrome. */
+ display?: TypeRole;
+}
+
+export interface ThemeStructure {
+ axis?: {
+ categorical?: AxisRole;
+ measure?: AxisRole;
+ };
+ /**
+ * Gridlines, bound by what the axis *does*, not by what it holds.
+ *
+ * `measure` is the grid the reader reads values off — the lines that run
+ * across the value axis. `category` is the grid across the axis the chart
+ * is indexed by, which on a scatter is the horizontal one even though it
+ * carries a number. Houses that rule only horizontally are asking for
+ * `measure` on and `category` off, whatever the two axes happen to hold.
+ */
+ grid?: {
+ measure?: Presence;
+ category?: Presence;
+ style?: 'solid' | 'dashed' | 'dotted';
+ /** Stroke width of visible gridlines in px. */
+ weight?: number;
+ /**
+ * A separate rule where the value axis crosses zero. `omit` leaves
+ * zero as an ordinary gridline; anything else draws it in its own
+ * weight. Only ever drawn when zero is inside the domain.
+ */
+ zero?: Presence;
+ };
+ frame?: Presence;
+ baseline?: Presence;
+}
+
+export interface ThemeMarks {
+ bandFraction?: number;
+ strokeWeight?: number;
+ strokeCap?: 'butt' | 'round' | 'square';
+ strokeJoin?: 'miter' | 'round' | 'bevel';
+ interpolation?: 'linear' | 'monotone' | 'step';
+ fillOpacity?: number;
+ /**
+ * How far the *value* end of a bar is rounded, in px — the top of a
+ * column, the right of a horizontal bar — and, on a wedge, its corners.
+ * Only the value end of a bar moves; the baseline stays a clean edge, so a
+ * stack still reads as one column. A house that says nothing keeps square
+ * corners; a friendlier, less clinical house rounds them.
+ */
+ cornerRadius?: number;
+ /**
+ * A stroke drawn around every filled mark — a bar, wedge, or point: the
+ * "sticker" / flat-illustration edge. It is not a `separator` (which cuts
+ * *between* adjacent pieces) nor a `frame` (which bounds the plot): it
+ * bounds each mark on its own, so a lone bar carries it too. A bar's
+ * outline stands down where the bar is too thin to hold it (so a dense bar
+ * chart keeps its fill); a grid cell is a field, held apart by a `tile`
+ * gap, not an outline. Large points keep the outline while dense point
+ * clouds may shrink the whole dot so the border does not turn the plot
+ * into a solid field. `ink` draws it in the house's dark structural ink;
+ * `surface` draws it in the page. A house that says nothing leaves its
+ * marks unbordered.
+ */
+ outline?: { presence?: Presence; weight?: number; source?: 'ink' | 'surface' };
+ sizeRange?: [number, number];
+ minSize?: number;
+ zOrder?: 'summaryOverData' | 'summaryUnderData';
+ separator?: { presence?: Presence; width?: number; source?: 'surface' | 'structure' };
+ /**
+ * A wedge sits in no band, so how far apart neighbouring wedges stand is
+ * its own question. A house may rule its stacked bars with a half-pixel
+ * hairline and still want a clean cut between the pieces of a pie: two
+ * arcs of the same size at different orientations are hard enough to
+ * compare without also having to find where one ends. Where the house
+ * says nothing, wedges are held apart the way its bars are.
+ *
+ * `rule` paints the shared edge in the separator's ink, which reads as a
+ * gap of constant width. `pad` swings the wedges apart instead, so the
+ * gap opens at the rim and closes to nothing at the centre.
+ */
+ slice?: { gap?: number; gapStyle?: 'rule' | 'pad' };
+ /**
+ * How far apart the cells of a grid stand — a heatmap, a calendar, a
+ * matrix. A cell is not a bar: it has no band to give back, its two
+ * neighbours are on two axes, and its colour is the reading, so a gap
+ * between cells has to be cut out of the shape the way a wedge's is.
+ *
+ * Whether to cut at all is a real difference between houses and not a
+ * detail. Flush cells read as a continuous field — the eye follows the
+ * gradient across a row and sees a season. Cut cells read as a table of
+ * separate readings, which is what a house wants when it prints the number
+ * inside each one. Where the house says nothing, cells are held apart the
+ * way its bars are.
+ *
+ * The gap is painted, not spaced, and it takes the surface unless the
+ * house asks for structure: on a grid whose colour *is* the value, an edge
+ * in any other ink reads as data.
+ */
+ tile?: { gap?: number; source?: 'surface' | 'structure' };
+ point?: {
+ /**
+ * Whether a *line* carries a dot at each of its vertices. This is a
+ * question about lines, not about dots: a scatter's dots are drawn
+ * because the chart is a scatter, and no house presence turns them off.
+ */
+ presence?: Presence;
+ /**
+ * How big a dot this house draws, as an area in px² — the way the
+ * renderer states a point's size and the way the size channel is read.
+ * One number, wherever a dot appears: at a line's vertex, on a scatter,
+ * at the ends of a dumbbell. A house that wanted its scatter dots and
+ * its vertex dots at different sizes would be saying that the same ink
+ * means two things, and the eye does not read them that way.
+ *
+ * Where the house says nothing the renderer's default stands. The
+ * layout remains free to shrink it when the plot runs short of room.
+ */
+ size?: number;
+ fill?: 'solid' | 'hollow';
+ halo?: { presence?: Presence; width?: number };
+ };
+ connector?: {
+ presence?: Presence;
+ /**
+ * The weight of a connector that runs from a mark to the baseline — a
+ * lollipop's stem. It restates nothing: the dot's position already
+ * carries the value and the stem only leads the eye down to the axis,
+ * so it is drawn as structure.
+ */
+ weight?: number;
+ /**
+ * The weight of a connector that runs between two marks — a dumbbell's
+ * bridge. This one is not redundant: the *distance* it draws is the
+ * reading, and a hairline asks the eye to measure a gap it can barely
+ * see. It keeps structure's ink and takes a mark's weight.
+ *
+ * Where the house says nothing, a bridge is drawn at the weight the
+ * house gives its lines: it is a mark, so it takes a mark's weight.
+ */
+ spanWeight?: number;
+ style?: 'solid' | 'dashed' | 'dotted';
+ };
+ trailingFill?: { presence?: Presence; opacity?: number };
+ interval?: { fillOpacity?: number; edge?: Presence; inkSource?: 'sameAsCentral' | 'structure' };
+ summary?: {
+ fill?: Presence;
+ outline?: Presence;
+ centralRule?: Presence;
+ widthFraction?: number;
+ };
+ observations?: { expose?: 'never' | 'whenSparse' | 'always'; maxRows?: number };
+ reference?: { presence?: Presence; style?: 'tick' | 'line' | 'dashed'; weight?: number; label?: boolean };
+ redundantEncoding?: Frequency;
+ redundantChannels?: Array<'shape' | 'dash' | 'texture' | 'lightness'>;
+}
+
+export interface ThemeLabels {
+ truncation?: 'never' | 'ellipsis' | 'wrap';
+ flush?: boolean;
+ angle?: 'auto' | 'horizontal' | 'rotated';
+}
+
+export type LegendPlacement =
+ | 'seriesEnd' | 'inline' | 'top' | 'right' | 'bottom' | 'left' | 'inside';
+
+export interface ThemeLegend {
+ show?: 'always' | 'never';
+ placement?: LegendPlacement[];
+ direction?: 'horizontal' | 'vertical';
+ /**
+ * `whenAmbiguous` asks whether the key's labels say what they are: a list
+ * of names (`Chrome`, `Cairo`) does, and a ramp of numbers does not.
+ */
+ title?: 'omit' | 'whenAmbiguous' | 'always';
+ gradientLength?: number;
+ /**
+ * The most entries a key to *values* may spend. A legend that names ten
+ * bubble sizes is a table, not a key: three well-chosen sizes tell the
+ * reader the scale and leave the chart the room.
+ */
+ maxSwatches?: number;
+ swatch?: 'auto';
+ /** The legend restates the categorical axis — delete it. */
+ suppressWhenAxisNames?: boolean;
+ /** The legend restates a number already printed in every mark — delete it. */
+ suppressWhenValuesPrinted?: boolean;
+}
+
+export interface ThemeDataLabels {
+ show?: 'always' | 'whenTheyFit' | 'never';
+ placement?: 'atMark' | 'outsideMark' | 'column';
+ inkMode?: 'fixed' | 'matchSeries' | 'contrastWithMark';
+}
+
+export interface ThemeAnnotation {
+ unit?: 'never' | 'firstTick' | 'lastTick' | 'firstAndLast' | 'everyTick';
+ /**
+ * `whenAmbiguous` asks the same question of each axis: `Jan Feb Mar` names
+ * its own kind and needs no title over it, `26 20 14` names nothing until
+ * one is written. Ranks and binned ranges count as numbers.
+ */
+ axisTitles?: 'omit' | 'whenAmbiguous' | 'always';
+ axisTitlePlacement?: 'rotated' | 'flatAboveAxis' | 'inline';
+ unitsInAxisTitle?: boolean;
+ numberFormat?: {
+ precision?: 'auto' | 'integer' | 'one' | 'two';
+ signed?: boolean;
+ thousands?: 'none' | 'separator' | 'suffix';
+ ordinal?: boolean;
+ };
+ pointEmphasis?: 'never' | 'endpoints' | 'latest' | 'extremes';
+ pointLabels?: 'never' | 'endpoints' | 'all';
+ statistics?: { show?: string[]; placement?: 'panel' | 'caption' };
+}
+
+export interface ThemeFurniture {
+ kind: 'mastheadTab' | 'footerRule' | 'headerRule';
+ anchor?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
+ color?: string;
+ width?: number;
+ height?: number;
+}
+
+export interface ThemeFacets {
+ header?: { presence?: Presence; style?: 'flushLabel' | 'boxedLabel'; fieldTitle?: 'omit' | 'always' };
+ panelFrame?: Presence;
+ axisRepetition?: 'everyPanel' | 'edgeOnly';
+ spacing?: 'compact' | 'normal' | 'airy';
+ preferredColumns?: number;
+ sharedScale?: 'always' | 'whenComparable' | 'never';
+}
+
+export interface ThemeLayout {
+ density?: 'compact' | 'normal' | 'airy';
+ targetWidth?: number;
+ titleBlock?: {
+ anchor?: 'start' | 'middle' | 'end';
+ /** Place the semantic title above the chart or as a caption below it. */
+ position?: 'top' | 'bottom';
+ /**
+ * The vertical gap between the title block and the chart below it — a
+ * house's whitespace personality reaching the headline. `tight` packs
+ * the chart up under the title (a dense figure, a dashboard tile);
+ * `loose` gives an action title room to breathe (a slide exhibit).
+ */
+ gap?: 'tight' | 'normal' | 'loose';
+ /** The vertical gap between the headline and its deck (subtitle). */
+ deckGap?: 'tight' | 'normal' | 'loose';
+ };
+ bandStep?: number;
+}
+
+/** A predicate over signals the compiler already resolves. Deliberately closed. */
+export interface ThemeGuard {
+ markChannel?: 'length' | 'position' | 'area' | 'angle' | 'color' | 'text';
+ hasBandedAxis?: boolean;
+ seriesCount?: NumericGuard;
+ categoryCount?: NumericGuard;
+ isPartToWhole?: boolean;
+ isSigned?: boolean;
+ isTemporal?: boolean;
+ isFaceted?: boolean;
+ isSummarised?: boolean;
+ canvasWidth?: NumericGuard;
+}
+
+export interface NumericGuard {
+ lt?: number; lte?: number; gt?: number; gte?: number; eq?: number;
+}
+
+export interface ThemeVariant {
+ when: ThemeGuard;
+ /** Policy blocks only — `ink` and `type` may not vary. */
+ then: Partial>;
+ /** Required: a variant without a stated reason is an inconsistency. */
+ because?: string;
+}
+
+/**
+ * The one block that is allowed to name a chart type.
+ *
+ * Everything else at level 1 is a policy the compiler binds to whatever chart
+ * it is handed. This is different in kind: it is the house's list of settings
+ * for charts it has an opinion about — the Times puts a dot on every reading
+ * of a line, and a bump chart it prints is never smoothed. Those are not
+ * consequences of a design language, they are house rules, and there is no
+ * honest way to derive them from ink and type.
+ *
+ * Keyed by chart type id, or `*` for every chart. Values are chart-property
+ * keys the template already declares. It is a *default*: anything the caller
+ * set explicitly wins, and a key the template does not offer is reported and
+ * dropped.
+ */
+export interface ThemeChartDefaults {
+ [chartType: string]: Record;
+}
+
+/**
+ * Compiler settings the house prefers — the size it draws at, how far a chart
+ * may stretch, how much air a facet gets. These are not style: they decide how
+ * much room the chart has before a single colour is chosen, which is why they
+ * cannot be applied after the fact like ink.
+ *
+ * Three levels, in order: what the caller put in the chart spec, then this,
+ * then flint's own defaults. A house sets the middle one.
+ */
+export interface ThemeCompileDefaults extends Partial {
+ baseSize?: { width: number; height: number };
+ canvasSize?: { width: number; height: number };
+}
+
+/**
+ * Level 1. One JSON document per design language.
+ *
+ * Every field is optional, including the ink and the type. A house that states
+ * nothing is not an error — it is the neutral house, and grounding it yields
+ * Flint's own defaults. That matters beyond tidiness: it is what lets the
+ * compiler reason about a chart's design (can it carry value labels? at this
+ * density?) when the caller named no house at all, without having to invent a
+ * second, parallel set of rules for the untheme'd case.
+ */
+export interface ThemeSpec {
+ /**
+ * Start from a theme Flint ships, then override only the fields this
+ * specification states. Nested objects merge; arrays and scalar values
+ * replace the preset value.
+ */
+ extends?: string;
+ id?: string;
+ label?: string;
+ ink?: ThemeInk;
+ type?: ThemeType;
+ structure?: ThemeStructure;
+ marks?: ThemeMarks;
+ labels?: ThemeLabels;
+ legend?: ThemeLegend;
+ dataLabels?: ThemeDataLabels;
+ annotation?: ThemeAnnotation;
+ furniture?: ThemeFurniture[];
+ facets?: ThemeFacets;
+ layout?: ThemeLayout;
+ chartDefaults?: ThemeChartDefaults;
+ compileDefaults?: ThemeCompileDefaults;
+ interaction?: { tooltipFormat?: string };
+ variants?: ThemeVariant[];
+}
+
+/**
+ * A house Flint ships, ready to name by id: `theme_spec: 'economist'`.
+ *
+ * Three parts, and they answer different questions. `spec` is what the
+ * compiler reads. `description` is how a caller chooses between houses.
+ * `guidance` is the house talking upstream.
+ *
+ * That last one needs saying, because the boundary is easy to blur. A theme
+ * governs the visual: ink, type, furniture, spacing. It does not choose the
+ * fields, the aggregation or the sort — the chart spec does, and it is written
+ * first. So where a house depends on something only the spec can give, it says
+ * so: which words it needs written, which annotations it reads, how many
+ * categories its colour can name. Facts and requests, not instructions — what
+ * to do about a tail of thirty categories is the author's call, and a house
+ * that starts prescribing transformations is overreaching. Hence a few lines.
+ */
+export interface ThemePreset {
+ id: string;
+ label: string;
+ /** One line: what this house is for. */
+ description: string;
+ /** A few markdown bullets: what this house needs the chart spec to do. */
+ guidance: string;
+ /**
+ * A 16px SVG standing in for the house in a picker, as a complete document
+ * so a caller can drop it straight into an `` or inline it.
+ *
+ * It is drawn from the house's own decisions rather than invented: the tile
+ * is its canvas, the bars are the first three of its categorical set, and
+ * the one thing left over says what the house does that the others do not —
+ * the Economist's red tab, Swiss's structural black rules, McKinsey's
+ * horizontal bars, Nature's bare axis, cartoon's rounded tops. At this size
+ * that is all a reader can take in, and it is enough to recognise the house
+ * once they have seen one chart in it.
+ */
+ icon: string;
+ spec: ThemeSpec;
+}
+
+// ---------------------------------------------------------------------------
+// Level 2 — grounded DesignDecisions
+// ---------------------------------------------------------------------------
+
+/**
+ * A downgrade or approximation. Silent fallbacks are indistinguishable from
+ * bugs, so every one of these is surfaced on `spec._theme.report`.
+ */
+export interface ThemeReport {
+ stage: 'ground' | 'realize';
+ /** Dotted ThemeSpec path this concerns, e.g. `legend.placement`. */
+ path: string;
+ message: string;
+}
+
+export interface ResolvedText {
+ font?: string;
+ fontSize?: number;
+ fontWeight?: 'normal' | 'bold' | number;
+ fontStyle?: 'normal' | 'italic';
+ color?: string;
+}
+
+export interface ResolvedRule {
+ show: boolean;
+ color: string;
+ width: number;
+ dash?: number[];
+}
+
+/** One axis, already bound to a screen channel. */
+export interface ResolvedAxis {
+ role: 'categorical' | 'measure';
+ /** `top`/`bottom` for x, `left`/`right` for y. */
+ orient: 'top' | 'bottom' | 'left' | 'right';
+ domain: ResolvedRule;
+ ticks: ResolvedRule & { size: number; offset: number };
+ grid: ResolvedRule;
+ label: ResolvedText & { show?: boolean; limit?: number; padding: number; flush?: boolean; angle?: number };
+ title: { show: boolean; placement?: 'rotated' | 'flatAboveAxis' | 'inline'; unit?: string } & ResolvedText;
+ /** Preferred tick count; undefined = let the renderer choose. */
+ tickCount?: number;
+ /**
+ * Which ticks carry a label. `all` leaves the choice to the renderer's own
+ * scale; the rest ask for the values the data actually holds, thinned or
+ * cut to the two ends.
+ */
+ tickLabels?: 'all' | 'observed' | 'endpoints' | 'sparse';
+ /** True when this axis carries what the reader indexes the chart by. */
+ indexing?: boolean;
+ /** A rule at zero, drawn only where the value axis crosses it. */
+ zeroRule?: ResolvedRule;
+ /** Suffix/prefix policy for the measure this axis carries. */
+ unit?: { text: string; where: 'never' | 'firstTick' | 'lastTick' | 'firstAndLast' | 'everyTick' };
+}
+
+export interface ResolvedSeriesInk {
+ mode: 'single' | 'categorical' | 'sequential' | 'diverging' | 'status';
+ single: string;
+ categorical: string[];
+ overflow?: string;
+ /**
+ * The data needs more inks than the house named, and the house named no
+ * overflow ink either. Colour can no longer tell the series apart, so the
+ * house set is not imposed — what is on the chart already was chosen for
+ * the count.
+ */
+ exhausted?: boolean;
+ /**
+ * More series than even the extended palette holds, but the house *does*
+ * name an {@link overflow} ink. The top {@link categorical}.length series
+ * by prominence take the indexed inks; every remaining ("other") series
+ * takes the one overflow ink. Realization orders the colour domain by
+ * share so it is the *smallest* series that fold into the overflow tail,
+ * not an arbitrary slice of the domain.
+ */
+ overflowTail?: boolean;
+ ramp?: Ramp;
+ status?: { positive?: string; negative?: string; neutral?: string };
+ /** Concrete range to hand a continuous colour scale (already sampled). */
+ range?: string[];
+ /** Set when the ramp is consumed as discrete bands. */
+ quantize?: number;
+}
+
+export interface ResolvedLegend {
+ show: boolean;
+ /** The placement that actually survived grounding. */
+ placement: LegendPlacement;
+ /**
+ * The rest of the house's ranked list, after the one that survived.
+ *
+ * Grounding cannot see everything: whether a name fits inside the band it
+ * names is a question of pixels and text, and it is answered in realize.
+ * When the answer comes back no, the house has already said what it would
+ * rather have — so the fallback is read from here rather than invented.
+ */
+ fallbacks?: LegendPlacement[];
+ orient?: 'top' | 'right' | 'bottom' | 'left' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'none';
+ direction?: 'horizontal' | 'vertical';
+ title: boolean;
+ label: ResolvedText;
+ gradientLength?: number;
+ /** The most entries a key to values may spend. */
+ maxSwatches?: number;
+}
+
+export interface ResolvedDataLabels {
+ show: boolean;
+ placement: 'atMark' | 'outsideMark' | 'column';
+ inkMode: 'fixed' | 'matchSeries' | 'contrastWithMark';
+ text: ResolvedText;
+ /** d3-format string derived from `annotation.numberFormat` + channel semantics. */
+ format?: string;
+ /**
+ * The unit each printed value carries. Set only where the house asks for
+ * a unit and no axis is left to state it — a pie has no ruler at all.
+ */
+ unit?: string;
+ /**
+ * Below this magnitude the mark is shorter than its own label, so an
+ * inside label would overrun it. Grounding owns this because it is a
+ * question about space, not about style.
+ */
+ insideMinValue?: number;
+ /**
+ * Above this magnitude the mark reaches the end of the scale, so an
+ * outside label would fall off the plot. The mirror of `insideMinValue`.
+ */
+ outsideMaxValue?: number;
+ /**
+ * The smallest share of the measure axis a stacked segment may occupy and
+ * still be labelled — a line of text over the plot's extent along that
+ * axis. Segments below it get no number: it would not fit between the
+ * segment's edges and would read as its neighbour's.
+ *
+ * A share rather than a value because the two stack modes divide by
+ * different totals — the tallest stack when the bars are summed, each
+ * bar's own total when they are normalized. Grounding owns it because
+ * only grounding knows the plot's size; by the time a spec is assembled
+ * the height may be a step or a container, not a number.
+ */
+ segmentMinShare?: number;
+ /**
+ * Whether this chart could carry value labels *at all* — structurally
+ * labelable, and not so dense that the numbers would be unreadable however
+ * firmly they were asked for.
+ *
+ * `show` is what the house decided; this is what the chart permits. A host
+ * reads it to know whether offering the reader a labels control is
+ * meaningful: where it is false the control can do nothing, so it is not
+ * shown rather than shown broken.
+ */
+ possible: boolean;
+}
+
+export interface ResolvedMarks {
+ bandFraction: number;
+ strokeWidth: number;
+ strokeCap?: string;
+ strokeJoin?: string;
+ interpolate?: string;
+ fillOpacity?: number;
+ /** Corner radius for the value end of a bar, and a wedge's corners, in px. */
+ cornerRadius?: number;
+ /** A stroke around each filled bar/wedge/point: the sticker edge (thin bars skip it). */
+ outline?: { color: string; width: number };
+ point?: { show: boolean; size?: number; filled?: boolean; haloColor?: string; haloWidth?: number };
+ /** The area a sized mark may take, smallest to largest, in px². */
+ sizeRange?: [number, number];
+ /** The area below which a sized mark stops being a mark. */
+ minSize?: number;
+ /** Whether the rows behind a summary mark are drawn alongside it. */
+ observations?: { expose: 'never' | 'whenSparse' | 'always'; maxRows: number };
+ separator?: { show: boolean; color: string; width: number };
+ /** How far apart neighbouring wedges of a pie or donut stand, in px. */
+ slice?: { gap: number; style: 'rule' | 'pad'; color: string };
+ /** How far apart the cells of a heatmap or matrix stand, in px. */
+ tile?: { gap: number; color: string };
+ /**
+ * `width` is the stem — a connector to the baseline. `spanWidth` is the
+ * bridge — a connector between two marks, which draws a distance that is
+ * itself the reading. Both are painted in `color`.
+ */
+ connector?: { show: boolean; color?: string; width: number; spanWidth: number; dash?: number[] };
+ interval?: { fillOpacity?: number; edge: boolean };
+ summary?: { fill: boolean; outline: boolean; centralRule: boolean; widthFraction?: number };
+ reference?: { show: boolean; width: number; style?: string; label: boolean };
+ zOrder: 'summaryOverData' | 'summaryUnderData';
+ redundantChannels: Array<'shape' | 'dash' | 'texture' | 'lightness'>;
+ redundantEncoding: Frequency;
+ /**
+ * The redundant channels grounding decided this chart actually gets, after
+ * weighing `redundantEncoding` against how hard the series are to tell
+ * apart by colour alone.
+ */
+ redundant: { shape: boolean; dash: boolean };
+}
+
+/** Level 2 output. Backend-neutral, but every role is bound to this chart. */
+export interface DesignDecisions {
+ themeId: string;
+ surface: { canvas: string; plot?: string; panel?: string };
+ /** Default text ink, for anything not otherwise specified. */
+ text: { primary: string; secondary: string; muted: string; inverse: string };
+ /** Base font family for the chart body. */
+ font?: string;
+ title: {
+ anchor: 'start' | 'middle' | 'end';
+ position: 'top' | 'bottom';
+ headline: ResolvedText;
+ deck: ResolvedText;
+ /** Gap from the title block to the chart, in px. */
+ offset: number;
+ /** Gap between the headline and its deck, in px. */
+ deckPadding: number;
+ };
+ /** Bound axes, keyed by screen channel. */
+ axes: { x?: ResolvedAxis; y?: ResolvedAxis };
+ frame: ResolvedRule;
+ baseline: ResolvedRule;
+ series: ResolvedSeriesInk;
+ legend: ResolvedLegend;
+ dataLabels: ResolvedDataLabels;
+ /**
+ * Which points on a line the house dots, and whether it writes the value
+ * there. Only meaningful where the chart draws a line through its data —
+ * stage 3 knows that, stage 2 does not.
+ */
+ pointEmphasis?: {
+ where: 'endpoints' | 'latest' | 'extremes';
+ labels: 'never' | 'endpoints' | 'all';
+ size: number;
+ };
+ marks: ResolvedMarks;
+ facets: {
+ header: { show: boolean; fieldTitle: boolean } & ResolvedText;
+ panelFrame: boolean;
+ axisRepetition: 'everyPanel' | 'edgeOnly';
+ spacing?: number;
+ preferredColumns?: number;
+ };
+ layout: { padding: number; density: 'compact' | 'normal' | 'airy' };
+ /**
+ * What the house prints alongside a fit: the quantities it expects to see
+ * stated, and where. Only meaningful where the chart actually fits
+ * something — stage 3 knows that, stage 2 does not.
+ */
+ statistics?: { show: string[]; placement: 'panel' | 'caption' } & ResolvedText;
+ furniture: ThemeFurniture[];
+ /** Chart facts stage 3 is allowed to consult (it may not re-derive them). */
+ bound: {
+ measureChannels: Array<'x' | 'y'>;
+ categoricalChannel?: 'x' | 'y';
+ seriesChannel?: string;
+ /** The field the series is keyed on, and the one the categorical axis names. */
+ seriesField?: string;
+ categoryField?: string;
+ seriesCount: number;
+ categoryCount: number;
+ isFaceted: boolean;
+ isPartToWhole: boolean;
+ isSigned: boolean;
+ /** The mark family, for realizers that must fake a missing primitive. */
+ markChannel: string;
+ };
+ report: ThemeReport[];
+}
diff --git a/packages/flint-js/src/core/theme/value-label-format.ts b/packages/flint-js/src/core/theme/value-label-format.ts
new file mode 100644
index 00000000..bcdb7e51
--- /dev/null
+++ b/packages/flint-js/src/core/theme/value-label-format.ts
@@ -0,0 +1,283 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * How many digits a value printed *on a mark* should carry, and how wide the
+ * result will be.
+ *
+ * A value label is a reading aid, not a table cell. It is read in place, next
+ * to its neighbours, in whatever room the mark leaves — so it wants the digits
+ * that let a reader take the value and compare it, and no more. Left to
+ * itself Vega-Lite prints the number as JavaScript renders it, which is how a
+ * tidy bar chart ends up captioned `3.14159265`.
+ *
+ * Two jobs live here, and they belong together because neither is right
+ * without the other: choosing the digits, and measuring what those digits will
+ * take up. Flint's fit tests — is the slot wide enough, is the segment thick
+ * enough — used to measure `String(Math.round(value))`, which is the width of
+ * a number nobody prints: it ignores decimals, separators, signs and the
+ * format itself. A chart of decimals was measured four times narrower than it
+ * drew, so the labels were offered and then overlapped.
+ */
+
+/** Significant digits a printed value carries at the top of its scale. */
+const SIGNIFICANT_DIGITS = 3;
+
+/**
+ * Past this, digits stop being information and start being magnitude: a
+ * reader takes `1.23M` off a chart faster than `1,234,567`, and the mark
+ * rarely has room for the latter anyway.
+ */
+const SUFFIX_ABOVE = 10_000;
+
+/** The SI suffixes d3-format uses, smallest to largest. */
+const SI_SUFFIX = ['y', 'z', 'a', 'f', 'p', 'n', 'µ', 'm', '', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
+
+/**
+ * The decimals the data itself carries.
+ *
+ * Read off `toFixed(10)`, which already discards floating-point noise
+ * (0.1 + 0.2 comes to 0.30000000000000004 but fixes to 0.3000000000), and
+ * capped at 6 — past that a value label is no longer being read, it is being
+ * transcribed.
+ */
+function dataDecimals(values: number[]): number {
+ let most = 0;
+ for (const v of values) {
+ if (!Number.isFinite(v)) continue;
+ const s = v.toFixed(10);
+ const dot = s.indexOf('.');
+ if (dot === -1) continue;
+ let end = s.length - 1;
+ while (end > dot && s[end] === '0') end -= 1;
+ const decimals = end > dot ? end - dot : 0;
+ if (decimals > most) most = decimals;
+ }
+ return Math.min(most, 6);
+}
+
+/** The largest magnitude in the data — the number that sets the width. */
+function maxMagnitude(values: number[]): number {
+ let max = 0;
+ for (const v of values) {
+ if (typeof v === 'number' && Number.isFinite(v)) max = Math.max(max, Math.abs(v));
+ }
+ return max;
+}
+
+/**
+ * The decimals it takes for the printed labels to keep the distinctions the
+ * marks are already showing.
+ *
+ * Precision chosen from magnitude alone answers "how big is this number", but
+ * a value label is read *against its neighbours*, and what a reader wants from
+ * it is often the difference. Eight bars of visibly different height captioned
+ * `100` eight times, or a row of fractions all captioned `0`, is a caption the
+ * chart itself contradicts — the worst thing a label can be, because the
+ * reader trusts the number over the pixels.
+ *
+ * So the series gets whatever decimals it takes to keep two values that differ
+ * printing differently, and to keep a value that is not zero from printing as
+ * zero. Never more than the data carries, and never more than six: past that
+ * the distinction is too fine to have been the point of the chart.
+ */
+function decimalsToDistinguish(values: number[], cap: number): number {
+ const distinct = new Set();
+ for (const v of values) if (Number.isFinite(v)) distinct.add(v);
+ if (distinct.size === 0) return 0;
+ const list = [...distinct];
+ for (let d = 0; d <= cap; d += 1) {
+ const scale = 10 ** d;
+ const printed = new Set();
+ let zeroedOut = false;
+ for (const v of list) {
+ const r = Math.round(v * scale) / scale;
+ if (r === 0 && v !== 0) zeroedOut = true;
+ printed.add(r);
+ }
+ if (!zeroedOut && printed.size === list.length) return d;
+ }
+ return cap;
+}
+
+/** Whether a d3 format pattern already states a precision (`.2f`, `.3~s`). */
+function statesPrecision(pattern: string): boolean {
+ return /\.\d/.test(pattern);
+}
+
+/**
+ * Choose the format a value label is printed with.
+ *
+ * The house's own pattern outranks this: a stated format is a decision
+ * someone made. But a house states a *style* — "use a k/M suffix", "group the
+ * thousands" — and a style says nothing about precision, which is why
+ * `~s` prints `1.23457M`. Where the house left the precision open, it is
+ * filled in from the data.
+ *
+ * Where the house stated one it is kept, with a single exception: a stated
+ * precision that would print two different values the same, or a value that
+ * is not zero as zero, is raised until it does not. `precision: 'integer'` is
+ * a house saying how numbers should read, not a house asking for eight bars
+ * of different heights all captioned `100`.
+ *
+ * With no house at all the whole pattern is inferred, because the alternative
+ * is Vega-Lite's raw rendering, and that is the case this exists to fix.
+ */
+export function inferValueLabelFormat(values: number[], house: string | undefined): string | undefined {
+ const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
+ if (nums.length === 0) return house;
+
+ const max = maxMagnitude(nums);
+ const sign = house?.startsWith('+') ? '+' : '';
+ // What it would take for the labels to stay as distinct as the marks.
+ // This is a floor on precision, not a target: it is consulted both when
+ // the house left the precision open and when it stated one, because a
+ // stated precision is a preference about how numbers should read, not a
+ // licence to print one the chart contradicts.
+ const needed = decimalsToDistinguish(nums, dataDecimals(nums));
+ if (house && statesPrecision(house)) {
+ // A suffix format states *significant* digits, not decimals, and
+ // shortens by design; the two rules do not compose, so it is left as
+ // the house wrote it.
+ if (/[se]$/.test(house)) return house;
+ const stated = Number(/\.(\d+)/.exec(house)?.[1] ?? 0);
+ if (stated >= needed) return house;
+ // Keep everything the house said — sign, grouping, suffix-free `f` —
+ // and raise only the digits, with `~` so the values that did not need
+ // them are not padded out with zeros.
+ return house.replace(/\.\d+~?f$/, `.${needed}~f`);
+ }
+ // A house that asked for a suffix keeps its suffix; it is only the number
+ // of digits in front of it that was left open. But a suffix means `k` and
+ // `M` — the house is asking to shorten large numbers, not to reach for SI
+ // in the other direction. d3 applies `s` both ways, and `0.00123` comes
+ // out as `1.23m`, which on a chart reads as millions. So the suffix is
+ // used only where every value is at least 1, and so cannot pick up a
+ // negative-exponent prefix.
+ let smallest = Infinity;
+ for (const v of nums) {
+ const a = Math.abs(v);
+ if (a > 0) smallest = Math.min(smallest, a);
+ }
+ const suffixIsSafe = max >= 1000 && (smallest === Infinity || smallest >= 1);
+ // A suffix is a deliberate shortening, but not to the point of printing
+ // two different bars the same. Three significant figures separate
+ // 123M from 988M; they do not separate 1,000,000 from 1,000,400.
+ const sigFigsHold = (() => {
+ const distinct = new Set(nums);
+ const rounded = new Set();
+ for (const v of distinct) rounded.add(Number(v.toPrecision(SIGNIFICANT_DIGITS)));
+ return rounded.size === distinct.size;
+ })();
+ const wantsSuffix = (house ? /s$/.test(house) : max >= SUFFIX_ABOVE) && suffixIsSafe && sigFigsHold;
+ if (wantsSuffix) return `${sign}.${SIGNIFICANT_DIGITS}~s`;
+
+ // Three significant digits at the top of the scale. `1999.9` keeps none of
+ // its decimals, `3.14159` keeps two — in each case the digits that
+ // separate one value from the next, and no more.
+ const grouping = house === undefined || house.includes(',') ? ',' : '';
+ const magnitude = max > 0 ? Math.floor(Math.log10(max)) : 0;
+ const wanted = SIGNIFICANT_DIGITS - 1 - magnitude;
+
+ // But a series is not all one size. Sizing the decimals off the largest
+ // value alone prints 0.001 and 0.05 both as `0` when a 5000 shares the
+ // axis — the small values are rounded out of existence, and a label that
+ // reads `0` on a bar that plainly is not zero is worse than no label. So
+ // the smallest value gets to claim the decimals it needs to say anything
+ // at all, and `~` trims the trailing zeros this leaves on the large ones,
+ // so `5000` is still printed `5,000` and not `5,000.000`.
+ const floor = smallest === Infinity ? 0 : Math.max(0, -Math.floor(Math.log10(smallest)));
+
+ // Below this even the exponent is shorter than the zeros in front of it.
+ if (max > 0 && max < 1e-4) return `${sign}.2~e`;
+
+ // Never invent precision the data does not have: whole numbers stay whole.
+ // `needed` is the exception that is not an exception — it is already
+ // bounded by what the data carries, so honouring it never invents a digit.
+ const decimals = Math.max(0, Math.min(Math.max(wanted, floor), dataDecimals(nums), 6), needed);
+ return decimals === 0 ? `${sign}${grouping}d` : `${sign}${grouping}.${decimals}~f`;
+}
+
+/**
+ * Render a value approximately as d3-format would.
+ *
+ * Approximately, and deliberately: flint-js carries no runtime dependencies,
+ * and what the fit tests need is the *width* of the label, not the label. This
+ * covers the patterns Flint itself produces and the ones a house can state;
+ * anything else falls back to the raw rendering, which is what Vega-Lite would
+ * print if the pattern were dropped, and is never narrower than the truth.
+ */
+export function formatValueApprox(value: number, pattern: string | undefined): string {
+ if (!Number.isFinite(value)) return '';
+ if (!pattern) return String(value);
+
+ const match = /^([+\-( ])?(,)?(?:\.(\d+))?(~)?([a-z%])?$/i.exec(pattern)
+ ?? /^([+\-( ])?(?:\.(\d+))?(~)?([a-z%])?(,)?$/i.exec(pattern);
+ if (!match) return String(value);
+ const forceSign = pattern.startsWith('+');
+ const group = pattern.includes(',');
+ const precisionText = /\.(\d+)/.exec(pattern)?.[1];
+ const precision = precisionText === undefined ? undefined : Number(precisionText);
+ const trim = pattern.includes('~');
+ const type = /([a-z%])\s*$/i.exec(pattern.replace(/,$/, ''))?.[1];
+
+ const negative = value < 0;
+ const abs = Math.abs(value);
+ let body: string;
+ let suffix = '';
+
+ if (type === 's') {
+ // SI: bring the mantissa into 1–999 and name the exponent.
+ const exponent = abs === 0 ? 0 : Math.floor(Math.log10(abs) / 3) * 3;
+ const clamped = Math.max(-24, Math.min(24, exponent));
+ const mantissa = abs / 10 ** clamped;
+ body = mantissa.toPrecision(precision ?? 6);
+ if (body.includes('e')) body = String(mantissa);
+ suffix = SI_SUFFIX[clamped / 3 + 8] ?? '';
+ } else if (type === '%') {
+ body = (abs * 100).toFixed(precision ?? 0);
+ suffix = '%';
+ } else if (type === 'd') {
+ body = String(Math.round(abs));
+ } else if (type === 'f') {
+ body = abs.toFixed(precision ?? 6);
+ } else if (type === 'e') {
+ body = abs.toExponential(precision ?? 6);
+ } else {
+ // No type: d3 renders the shortest form that keeps the precision, so
+ // the raw rendering is the honest estimate.
+ body = precision === undefined ? String(abs) : String(Number(abs.toPrecision(precision)));
+ }
+
+ // `~` drops trailing zeros — and the point along with them.
+ if (trim && body.includes('.')) {
+ const [mantissa, exponent] = body.split(/e/i);
+ const trimmed = mantissa.replace(/0+$/, '').replace(/\.$/, '');
+ body = exponent === undefined ? trimmed : `${trimmed}e${exponent}`;
+ }
+
+ if (group) {
+ const dot = body.indexOf('.');
+ const whole = dot === -1 ? body : body.slice(0, dot);
+ const rest = dot === -1 ? '' : body.slice(dot);
+ body = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + rest;
+ }
+
+ const signText = negative ? '-' : forceSign ? '+' : '';
+ return `${signText}${body}${suffix}`;
+}
+
+/**
+ * The longest label this data will print, in characters.
+ *
+ * This is what the fit tests must measure: a slot has to hold the widest
+ * number that will land in it, not the widest number in some other notation.
+ */
+export function longestLabelChars(values: number[], pattern: string | undefined): number {
+ let longest = 1;
+ for (const v of values) {
+ if (typeof v !== 'number' || !Number.isFinite(v)) continue;
+ longest = Math.max(longest, formatValueApprox(v, pattern).length);
+ }
+ return longest;
+}
diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts
index 4e6db304..409f9fdd 100644
--- a/packages/flint-js/src/core/types.ts
+++ b/packages/flint-js/src/core/types.ts
@@ -5,6 +5,7 @@ import type { ZeroDecision, ColorSchemeRecommendation } from './semantic-types';
import type { LabelSizingDecision } from './decisions';
import type { SemanticAnnotation, FormatSpec, DomainConstraint, TickConstraint } from './field-semantics';
import type { ColorDecisionResult } from './color-decisions';
+import type { ThemeSpec } from './theme/types';
/**
* Core types for the chart engine library.
@@ -942,6 +943,13 @@ export interface ChartTemplateDef {
/** Optional configurable properties for the chart type */
properties?: ChartPropertyDef[];
+ /**
+ * This template draws its own value text instead of using the generic
+ * theme label layer. The public control is still `showValueLabels`;
+ * templates may retain older internal/input spellings for compatibility.
+ */
+ ownsValueLabels?: boolean;
+
/**
* Opt out of a backend's *generic* column/row facet-splitting pass, even
* though the template declares `x`/`y` (so the axis-less `hasAxes` gate
@@ -1061,6 +1069,19 @@ export interface ChartAssemblyInput {
chart_spec: {
/** Template name, e.g. `"Scatter Plot"`, `"Bar Chart"` */
chartType: string;
+ /**
+ * The headline — what this chart says, in words.
+ *
+ * Not decoration. A chart of bare numbers names nothing on its own, and
+ * the headline is where the measure gets named: `Male` and `75+` say
+ * what they are, `35 30 25` does not. Design languages that drop axis
+ * titles are leaning on this line to carry the subject, so a chart
+ * authored without one loses the naming altogether — the compiler
+ * notices, and puts the axis titles back.
+ */
+ title?: string;
+ /** The deck: the reading of the headline — what is measured, of whom, when, in what units. */
+ subtitle?: string;
/** Channel → encoding map (e.g., `{ x: { field: 'weight' }, y: { field: 'mpg' } }`).
* A bare string is shorthand for `{ field: }` (e.g. `{ x: 'weight' }`). */
encodings: Record;
@@ -1089,6 +1110,26 @@ export interface ChartAssemblyInput {
chartProperties?: Record;
};
+ /**
+ * Theme — describes *how it should look*.
+ *
+ * Either the name of a house Flint ships (`'economist'`, `'nature'`, …see
+ * `listThemePresets()`), a `ThemeSpec` of your own, or a `ThemeSpec` that
+ * `extends` a shipped house and overrides selected fields. A ThemeSpec is a
+ * portable design language (ink, type, structure, marks, chrome policy),
+ * stated without ever naming a channel, field, or backend property. The
+ * compiler grounds it against this chart and then realizes it in the
+ * target backend.
+ *
+ * Sits beside `chart_spec` rather than inside it because the same theme
+ * applies to every chart and the same chart accepts any theme — nesting it
+ * would make that independence unstatable.
+ *
+ * Currently realized by the Vega-Lite assembler only. Other assemblers
+ * accept the shared input field but do not apply it.
+ */
+ theme_spec?: ThemeSpec | string;
+
/**
* Options for the assembler — layout tuning, tooltips, etc.
* All fields are optional and have sensible defaults.
@@ -1204,6 +1245,20 @@ export interface AssembleOptions {
* Default: 20.
*/
defaultBandSize?: number;
+ /**
+ * Chart-specific **floor** on the per-category band step (at a 300px
+ * baseline canvas), which a house's `layout.bandStep` may grow but not
+ * undercut. Ordinary charts have no such floor: a compact house is right
+ * to print thin bars. But a few chart types are only legible above a
+ * minimum band width regardless of house — a slopegraph draws its whole
+ * meaning from the angle of two columns, and a house that packs them 46px
+ * apart turns every slope near-vertical and leaves no room for the end
+ * labels. Such a template states the width its read needs here, and the
+ * house is held to it as a minimum while still free to spread wider.
+ *
+ * Unset for most templates (no floor). Set via paramOverrides.
+ */
+ minBandStep?: number;
/**
* Maximum pixels per discrete category at a 300px baseline canvas,
* scaled proportionally with canvas size (like {@link defaultBandSize}).
@@ -1222,6 +1277,22 @@ export interface AssembleOptions {
* Defaults to {@link defaultBandSize} (no expansion beyond the base band).
*/
maxBandSize?: number;
+ /**
+ * When a discrete axis is **grouped** (dodged lanes within each category
+ * band), by default the band's elastic stretch target is the *per-item*
+ * step — so a category holding N lanes is only sized as if it held one.
+ * For thin marks (grouped bars) that's fine: 4 lanes share a ~66px band
+ * comfortably. But wide marks — a box-and-whisker glyph — need real room
+ * per lane, and the per-item target leaves grouped boxplots compressed on
+ * an otherwise roomy canvas.
+ *
+ * When set, the grouped band instead targets `itemsPerGroup × step`, so
+ * the category band stretches to give each lane its full width (still
+ * bounded by `maxBandSize × itemsPerGroup` and the canvas budget). Scoped
+ * to templates whose grouped glyph is wide (boxplot); left off for bars so
+ * their tuned grouped spacing does not change.
+ */
+ groupBandFillsLanes?: boolean;
/**
* Backend-native base font size (px) for axis **tick labels**, at a 300px
* reference canvas. The core scales it subtly with canvas size and uses it
diff --git a/packages/flint-js/src/docs/test_plan.md b/packages/flint-js/src/docs/test_plan.md
deleted file mode 100644
index 6777d8df..00000000
--- a/packages/flint-js/src/docs/test_plan.md
+++ /dev/null
@@ -1,369 +0,0 @@
-# Chart Engine Test Plan
-
-## Overview
-
-Test data lives in `test-data/` as fixture generators (not executable test suites).
-Each file exports generator functions that produce `TestCase[]` arrays. The gallery
-UI (`ChartGallery.tsx`) uses `TEST_GENERATORS` and `GALLERY_SECTIONS` from
-`test-data/index.ts` to render all tests interactively.
-
-**20 test-data files**, **~11,100 lines**, **53 named test generators**.
-
-### Test categories
-
-| Category | Files | Description |
-|----------|-------|-------------|
-| **VL chart matrices** | scatter-tests, line-tests, bar-tests, area-tests | Matrix-driven tests for core VL chart types |
-| **Distribution charts** | distribution-tests | Histogram, Boxplot, Density, Strip Plot |
-| **Specialized charts** | specialized-tests | Pie, Heatmap, Lollipop, Candlestick, Waterfall, Ranged Dot, Bump, Radar, Pyramid, Rose, Custom |
-| **Semantic context** | semantic-tests | 39 tests validating semantic type → ChannelSemantics resolution |
-| **ECharts backend** | echarts-tests | All ECharts chart types (reuses VL inputs + ECharts-only types) |
-| **Chart.js backend** | chartjs-tests | Chart.js chart types |
-| **Facets** | facet-tests | Column, row, col+row, wrap, clip, overflow faceting |
-| **Stress/sizing** | stress-tests, gas-pressure-tests, line-area-stretch-tests, discrete-axis-tests | Overflow, elasticity, pressure model, discrete axis sizing |
-| **Temporal** | date-tests | Year, Month, YearMonth, Decade, DateTime, Hours parsing/formatting |
-| **Line/area variants** | line-area-tests | Bump Chart |
-
----
-
-## Scatter Plot
-
-A scatter plot places marks (points/bubbles) in a 2D space. The core axes are continuous (quantitative), but one or both axes can be discrete (nominal), which changes how the engine computes layout, step sizing, and overflow.
-
-Temporal axes are omitted from scatter tests because T behaves identically to Q in scatter layout — no special handling. Temporal is still tested as a color channel (`color: 'T'`).
-
-Default test canvas: 300 × 300 px.
-
-### Matrix-driven approach
-
-Tests are generated from a **declarative matrix** (`SCATTER_MATRIX` in `scatter-tests.ts`). Each row describes one test via its axis types, optional third channels, cardinality, and special flags. A generator function converts each matrix entry into a full `TestCase`.
-
-### Matrix dimensions
-
-| Dimension | Values | Notes |
-|-----------|--------|-------|
-| **x axis type** | Q, N | Quantitative, Nominal |
-| **y axis type** | Q, N | Same |
-| **color channel** | —, Q, T, N | Optional 3rd encoding |
-| **size channel** | —, Q, N | Optional 4th encoding |
-| **n (density)** | 10–500 | Or 0 for N×N grid mode |
-| **cardinality** | xCard, yCard, colorCard, sizeCard | Cardinality of nominal dims |
-| **flags** | hugeRange | Special data distributions |
-
-### Full test matrix (25 tests)
-
-#### Q × Q — 15 tests
-
-| # | color | size | n | flags | what it tests |
-|---|-------|------|---|-------|---------------|
-| 1 | — | — | 20 | | Baseline scatter |
-| 2 | N(3) | — | 20 | | Nominal color groups |
-| 3 | Q | — | 20 | | Continuous color gradient |
-| 4 | T | — | 30 | | Temporal color gradient |
-| 5 | — | Q | 50 | | Bubble chart |
-| 6 | — | N(4) | 20 | | Ordinal size — 4 ranked levels |
-| 7 | N(3) | Q | 15 | | Gapminder-style |
-| 8 | Q | Q | 30 | | Dual continuous (4D) |
-| 9 | N(20) | Q | 20 | hugeRange | Size 1K–1B, sqrt scale |
-| 10 | — | — | 100 | | Moderate density |
-| 11 | — | — | 500 | | High density |
-| 12 | N(20) | — | 200 | | Dense, many groups |
-| 13 | N(50) | — | 100 | | Legend overflow |
-| 14 | — | Q | 10 | | Sparse bubbles |
-| 15 | — | Q | 200 | | Dense bubbles |
-
-#### N × Q — 4 tests
-
-| # | xCard | color | size | n | what it tests |
-|---|-------|-------|------|---|---------------|
-| 1 | 5 | Q | — | 25 | Strip + continuous color |
-| 2 | 5 | — | Q | 25 | Bubble strip |
-| 3 | 2 | — | — | 30 | Binary category strip (edge) |
-| 4 | 60 | — | — | 60 | 60 cats — overflow |
-
-#### Q × N — 3 tests (mirrors N×Q with flipped orientation)
-
-| # | yCard | color | size | n | what it tests |
-|---|-------|-------|------|---|---------------|
-| 1 | 5 | Q | — | 25 | Horizontal strip + continuous color |
-| 2 | 5 | — | Q | 25 | Horizontal bubble strip |
-| 3 | 60 | — | — | 60 | Horizontal 60-cat overflow |
-
-#### N × N — 3 tests
-
-| # | xCard | yCard | color | size | what it tests |
-|---|-------|-------|-------|------|---------------|
-| 1 | 5 | 6 | — | Q | Bubble grid |
-| 2 | 5 | 4 | Q | — | Heatmap-like grid |
-| 3 | 15 | 12 | — | Q | Large grid — overflow |
-
-### Coverage summary
-
-Axis combos: Q×Q, N×Q, Q×N, N×N. Third-channel variants (color and size, typed Q/T/N) crossed with Q×Q. Density from 10 to 500. Edge cases: binary categories, legend overflow, huge value ranges.
-
-### How to add a test
-
-Add one row to `SCATTER_MATRIX` in `scatter-tests.ts`:
-
-```typescript
-{ x: 'N', y: 'Q', n: 40, xCard: 8, color: 'Q', desc: 'Strip + continuous color, moderate density' },
-```
-
-The generator handles field naming, data synthesis, metadata, tags, and title automatically.
-
----
-
-## Line Chart
-
-A line chart connects data points with lines. Lines imply sequential progression, so axes use T (temporal), O (ordinal), or Q (quantitative) — never purely nominal. N (nominal) is used only for color groups.
-
-Channels: `x, y, color, opacity, column, row`
-
-### Matrix-driven approach
-
-Tests are generated from `LINE_MATRIX` in `line-tests.ts`. Each row specifies axis types, optional color channel, point count, and flags like `sparse` (20% dropout).
-
-### Full test matrix (16 tests)
-
-#### T × Q — 6 tests (core time series)
-
-| # | color | n | flags | what it tests |
-|---|-------|---|-------|---------------|
-| 1 | — | 30 | | Simple time series |
-| 2 | N(4) | 200 | | 4 series × 50 dates |
-| 3 | N(8) | 800 | | 8 series crowded |
-| 4 | N(20) | 4000 | stress | 20 series spaghetti |
-| 5 | N(3) | 180 | sparse | 3 series, ~20% missing |
-| 6 | Q | 30 | | Continuous color gradient |
-
-#### O × Q — 4 tests (ordinal x)
-
-| # | xCard | color | n | what it tests |
-|---|-------|-------|---|---------------|
-| 7 | 5 | — | 5 | Ordinal line |
-| 8 | 12 | N(4) | 48 | 12 ordinal × 4 series |
-| 9 | 30 | — | 30 | Label overflow |
-| 10 | 5 | Q | 5 | Ordinal + gradient |
-
-#### Q × Q — 3 tests
-
-| # | color | n | what it tests |
-|---|-------|---|---------------|
-| 11 | — | 30 | Quantitative x line |
-| 12 | N(3) | 150 | 3 parametric curves |
-| 13 | — | 200 | Dense single curve |
-
-#### Q × O — 3 tests (mirror)
-
-| # | yCard | color | n | what it tests |
-|---|-------|-------|---|---------------|
-| 14 | 5 | — | 5 | Horizontal ordinal |
-| 15 | 12 | N(4) | 48 | Horizontal 12 ordinal × 4 |
-| 16 | 30 | — | 30 | Horizontal 30 ordinal overflow |
-
-#### Excluded combos
-
-- **T×T, Q×T** — date-pair data (start vs end date) doesn't suit line charts. Each row is an independent event, not a sequential series; lines connect points in data order producing random zig-zags. Better served by scatter or dumbbell charts.
-- **O×O** — ordinal×ordinal lines are degenerate.
-- **N×N, T×N, N×T** — purely nominal axes don't suit line charts. Lines imply sequence/progression; connecting unordered categories is misleading.
-
-### Coverage summary
-
-Axis combos: T×Q, O×Q, Q×Q, Q×O. Color variants (N, Q) crossed with primary combos. Density from 5 to 4000 (stress). Sparse dropout tests irregular gaps. Total **16 tests**.
-
----
-
-## Bar Chart / Stacked Bar Chart / Grouped Bar Chart
-
-Bar charts encode values as rectangular bars. Three variants share a common matrix format in `bar-tests.ts`:
-- **Bar Chart**: `x, y, color, opacity` — basic bars with optional color
-- **Stacked Bar Chart**: `x, y, color` — bars stacked by color dimension
-- **Grouped Bar Chart**: `x, y, group` — bars side-by-side by group dimension
-
-### Matrix-driven approach
-
-Three matrices (`BAR_MATRIX`, `STACKED_BAR_MATRIX`, `GROUPED_BAR_MATRIX`) share one generator function `barMatrixToTestCase`. The third channel key is `'color'` for bar/stacked and `'group'` for grouped.
-
-### Bar Chart matrix (19 tests)
-
-#### N × Q — 6 tests (classic vertical)
-
-| # | xCard | color | n | what it tests |
-|---|-------|-------|---|---------------|
-| 1 | 5 | — | 5 | Basic 5 bars |
-| 2 | 20 | — | 20 | Label rotation |
-| 3 | 30 | — | 30 | Thin bar handling |
-| 4 | 100 | — | 100 | Discrete cutoff |
-| 5 | 5 | N(3) | 15 | 5 cats × 3 colors |
-| 6 | 5 | N(20) | 100 | Color saturation |
-
-#### Q × N — 3 tests (horizontal)
-
-| # | yCard | color | n | what it tests |
-|---|-------|-------|---|---------------|
-| 7 | 10 | — | 10 | Horizontal 10 bars |
-| 8 | 100 | — | 100 | Horizontal cutoff |
-| 9 | 10 | N(3) | 30 | Horizontal + 3 colors |
-
-#### T × Q — 3 tests (temporal)
-
-| # | color | n | what it tests |
-|---|-------|---|---------------|
-| 10 | — | 24 | Temporal bars |
-| 11 | — | 100 | 100 dates — dynamic sizing |
-| 12 | N(3) | 72 | Temporal + 3 colors |
-
-#### Q × T — 2 tests (horizontal temporal)
-
-| # | color | n | what it tests |
-|---|-------|---|---------------|
-| 13 | — | 18 | Horizontal temporal |
-| 14 | N(3) | 54 | Horizontal temporal + color |
-
-#### Q × Q — 2 tests (continuous banded)
-
-| # | n | what it tests |
-|---|---|---------------|
-| 15 | 20 | Both quant — dynamic resizing |
-| 16 | 30 | Equally spaced 1..30 |
-
-#### Edge combos — 3 tests
-
-| # | x | y | n | what it tests |
-|---|---|---|---|---------------|
-| 17 | N | N | grid | Cat × cat (degenerate) |
-| 18 | T | T | 20 | Date × date (degenerate) |
-| 19 | T | N | 25 | Temporal × categorical |
-
-### Stacked Bar Chart matrix (12 tests)
-
-| # | x | y | color | n | what it tests |
-|---|---|---|-------|---|---------------|
-| 1 | N | Q | N(3) | 12 | Basic stack 4×3 |
-| 2 | N | Q | N(5) | 75 | Large 15×5 |
-| 3 | N | Q | N(3) | 240 | Very large 80×3 (cutoff) |
-| 4 | N | Q | Q(4) | 24 | Numeric color (1–4) |
-| 5 | N | Q | Q(30) | 150 | Numeric color (1–30) |
-| 6 | T | Q | N(3) | 30 | Temporal stack |
-| 7 | T | Q | N(4) | 80 | 20 dates × 4 |
-| 8 | Q | Q | N(3) | 30 | Both quant stacked |
-| 9 | Q | N | N(3) | 24 | Horizontal stack |
-| 10 | Q | T | N(3) | 45 | Horizontal temporal stack |
-| 11 | N | N | N(3) | grid | Cat×cat stacked (edge) |
-| 12 | T | T | N(3) | 30 | Date×date stacked (edge) |
-
-### Grouped Bar Chart matrix (12 tests)
-
-| # | x | y | group | n | what it tests |
-|---|---|---|-------|---|---------------|
-| 1 | N | Q | N(3) | 12 | Basic grouped 4×3 |
-| 2 | N | Q | — | 8 | No group — fallback |
-| 3 | N | Q | N(3) | 270 | Very large 90×3 (cutoff) |
-| 4 | N | Q | Q(5) | 30 | Numeric group (1–5) |
-| 5 | T | Q | N(3) | 36 | Temporal grouped |
-| 6 | Q | Q | N(4) | 20 | Both quant + group |
-| 7 | Q | N | N(4) | 24 | Horizontal grouped |
-| 8 | Q | T | N(3) | 30 | Horizontal temporal grouped |
-| 9 | N | Q | Q(50) | 400 | Numeric group (1–50) |
-| 10 | N | Q | Q | 50 | Continuous float on group |
-| 11 | N | N | N(3) | grid | Cat×cat grouped (edge) |
-| 12 | T | T | N(3) | 30 | Date×date grouped (edge) |
-
-### Coverage summary
-
-All three bar variants cover common xy-type combinations. Bar Chart has 19 tests, Stacked Bar has 12, Grouped Bar has 12 — total **43 bar tests**. Covers horizontal/vertical orientation, discrete cutoff, numeric/continuous color, edge combos.
-
-## Area Chart & Streamgraph
-
-**File:** `area-tests.ts`
-**Approach:** Matrix-driven — `AREA_MATRIX` (17 entries) + `STREAMGRAPH_MATRIX` (6 entries).
-**Shared generator:** `areaMatrixToTestCase(entry, chartType, rand)` — same infrastructure for both chart types.
-**Data characteristic:** Uses `genAreaTrend()` with upward drift (natural for cumulative / stacked-area metrics).
-
-Area charts use O (ordinal) for categorical axes (like line charts) — area fills imply continuity. N (nominal) is used only for color groups. Purely nominal axis combos are excluded.
-
-### Area Chart matrix (17 tests)
-
-#### T × Q — 7 tests (core stacked / layered area)
-
-| # | color | n | flags | what it tests |
-|---|-------|---|-------|---------------|
-| 1 | — | 30 | | Simple time-series area |
-| 2 | N(4) | 96 | | 4 stacked series |
-| 3 | N(8) | 480 | | 8 series large stacked |
-| 4 | N(15) | 1800 | stress | 15 series stress |
-| 5 | N(3) | 120 | | 3 layered/overlapping |
-| 6 | N(3) | 180 | sparse | 3 series, ~20% missing |
-| 7 | Q | 30 | | Continuous color gradient |
-
-#### O × Q — 4 tests (ordinal x)
-
-| # | xCard | color | n | what it tests |
-|---|-------|-------|---|---------------|
-| 8 | 5 | — | 5 | Ordinal area 5 cats |
-| 9 | 12 | N(4) | 48 | 12 ordinal × 4 stacked |
-| 10 | 30 | — | 30 | 30 ordinal overflow |
-| 11 | 5 | Q | 5 | Ordinal + continuous color |
-
-#### Q × O — 3 tests (mirror)
-
-| # | yCard | color | n | what it tests |
-|---|-------|-------|---|---------------|
-| 12 | 5 | — | 5 | Horizontal ordinal 5 cats |
-| 13 | 12 | N(4) | 48 | Horizontal 12 ordinal × 4 |
-| 14 | 30 | — | 30 | Horizontal 30 ordinal overflow |
-
-#### Q × Q — 3 tests
-
-| # | color | n | what it tests |
-|---|-------|---|---------------|
-| 15 | — | 30 | Quantitative x area |
-| 16 | N(3) | 150 | 3 stacked curves |
-| 17 | — | 200 | Dense single-series |
-
-#### Excluded combos
-
-- **T×T, Q×T** — date-pair data doesn't suit area charts. Area fills imply sequential progression; T×T/Q×T lack monotonic relationships.
-- **N×N, T×N, N×T** — purely nominal axes don't suit area charts. Area fills imply continuity/progression; nominal axes lack this.
-
-### Streamgraph matrix (6 tests)
-
-| # | x | y | color | n | what it tests |
-|---|---|---|-------|---|---------------|
-| 1 | T | Q | N(5) | 200 | 5 genres basic streamgraph |
-| 2 | T | Q | N(10) | 800 | 10 industries large |
-| 3 | T | Q | N(20) | 3000 | 20 series stress |
-| 4 | T | Q | N(5) | 200 | 5 series ~20% sparse |
-| 5 | O | Q | N(5) | 60 | Ordinal streamgraph |
-| 6 | Q | Q | N(3) | 150 | Quant-x streamgraph |
-
-### Coverage summary
-
-Area Chart covers T×Q, O×Q, Q×O, Q×Q axis combos (17 tests). Streamgraph adds 6 tests exercising T×Q, O×Q, and Q×Q with multi-series color. Total **23 area/streamgraph tests**.
-
----
-
-## Grand total (matrix-driven chart tests)
-
-| Chart type | Tests |
-|------------|-------|
-| Scatter | 25 |
-| Line | 16 |
-| Bar | 19 |
-| Stacked Bar | 12 |
-| Grouped Bar | 12 |
-| Area | 17 |
-| Streamgraph | 6 |
-| **Matrix subtotal** | **107** |
-
-Plus additional non-matrix test generators:
-- Distribution charts (Histogram, Boxplot, Density, Strip)
-- Specialized charts (Pie, Heatmap, Lollipop, Candlestick, Waterfall, etc.)
-- Semantic context (39 tests)
-- Facets (9 generators)
-- Stress/sizing (4 generators)
-- Temporal (7 generators)
-- ECharts backend (24 generators)
-- Chart.js backend (11 generators)
-
-**43 named test generators** total across all categories.
diff --git a/packages/flint-js/src/echarts/assemble.ts b/packages/flint-js/src/echarts/assemble.ts
index 638c081c..64e36bc4 100644
--- a/packages/flint-js/src/echarts/assemble.ts
+++ b/packages/flint-js/src/echarts/assemble.ts
@@ -94,6 +94,24 @@ import { normalizeChartProperties } from '../core/normalize-properties';
*
* @returns An ECharts option object with optional `_warnings` and `_width`/`_height` hints
*/
+function applyFieldDisplayNames(option: any, names: Record | undefined): void {
+ if (!names) return;
+ const displayName = (value: unknown) => typeof value === 'string' ? names[value] ?? value : value;
+ for (const axisKey of ['xAxis', 'yAxis', 'singleAxis']) {
+ const axes = Array.isArray(option[axisKey]) ? option[axisKey] : [option[axisKey]];
+ for (const axis of axes) {
+ if (axis?.name) axis.name = displayName(axis.name);
+ }
+ }
+ for (const series of option.series ?? []) {
+ if (series?.name) series.name = displayName(series.name);
+ }
+ const graphics = Array.isArray(option.graphic) ? option.graphic : option.graphic ? [option.graphic] : [];
+ for (const graphic of graphics) {
+ if (graphic?.style?.text) graphic.style.text = displayName(graphic.style.text);
+ }
+}
+
export function assembleECharts(input: ChartAssemblyInput): any {
const chartType = input.chart_spec.chartType;
const semanticTypes = input.semantic_types ?? {};
@@ -534,6 +552,8 @@ export function assembleECharts(input: ChartAssemblyInput): any {
// Clean internal-only props
delete ecOption._legendWidth;
+ applyFieldDisplayNames(ecOption, input.field_display_names);
+
return ecOption;
}
diff --git a/packages/flint-js/src/echarts/instantiate-spec.ts b/packages/flint-js/src/echarts/instantiate-spec.ts
index 3d588e63..b4474d4c 100644
--- a/packages/flint-js/src/echarts/instantiate-spec.ts
+++ b/packages/flint-js/src/echarts/instantiate-spec.ts
@@ -257,6 +257,15 @@ function pyramidNiceTickStep(niceMax: number): number {
return niceMax / 4;
}
+/**
+ * Overlay companions: the scatter series a boxplot draws alongside itself for
+ * its outliers or for the full raw sample. They are separate series only
+ * because ECharts needs a different series type to draw them, so they must
+ * take the colour of the box they sit on rather than the next palette slot.
+ */
+const COMPANION_SUFFIX = / \((?:outliers|points)\)$/;
+const isCompanionSeries = (s: any): boolean => s?._companion === true;
+
/**
* Grouped boxplot needs enough per-category horizontal room; otherwise boxes overlap.
* Return a conservative minimum plot width for category x-axis grouped boxplots.
@@ -300,7 +309,7 @@ function placePyramidChannelHeaders(option: any): void {
const gw = Math.max(0, cw - gl - gr);
const centerX = gl + gw / 2;
const dx = gw / 4;
- const topY = Math.max(4, gt - 10);
+ const topY = Math.max(4, gt - 24);
const L = estimatePyramidYCategoryInsetPx(option, gw);
const innerW = Math.max(gw - L, 1);
@@ -1008,8 +1017,7 @@ export function ecApplyLayoutToSpec(
// 当存在调色板时,覆盖模板中的硬编码 itemStyle.color,
// 让最终颜色真正由 colorDecisions / colormap 注册表驱动。
- if (effectivePalette && effectivePalette.length > 0 && Array.isArray(option.series)) {
- const palette_ = effectivePalette; // local const so TS narrows inside closures
+ if (effectivePalette && effectivePalette.length > 0 && Array.isArray(option.series)) { const palette_ = effectivePalette; // local const so TS narrows inside closures
const n = palette_.length;
const schemeType = colorDecision?.schemeType;
@@ -1082,13 +1090,13 @@ export function ecApplyLayoutToSpec(
option.series.forEach((s: any, idx: number) => {
if (!s) return;
const rawName: string = typeof s.name === 'string' ? s.name : (s.name != null ? String(s.name) : '');
- const baseName = rawName.endsWith(' (outliers)')
- ? rawName.slice(0, -' (outliers)'.length)
- : rawName;
+ const baseName = rawName.replace(COMPANION_SUFFIX, '');
const mappedColor = baseName && categoryToColor.has(baseName)
? categoryToColor.get(baseName)
: palette_[idx % n];
s.itemStyle = s.itemStyle || {};
+ // A hollow boxplot (raw sample overlaid) declares a per-datum
+ // transparent fill; the palette still owns its outline.
s.itemStyle.color = mappedColor!;
if (s.type === 'boxplot') {
s.itemStyle.borderColor = mappedColor!;
@@ -1098,7 +1106,7 @@ export function ecApplyLayoutToSpec(
// ECharts default palette: first = blue (#5470c6), fourth = red (#ee6666) — not adjacent greens.
const pal = effectivePalette && effectivePalette.length > 0 ? effectivePalette : DEFAULT_COLORS;
const cLeft = pal[0];
- const cRight = pal.length > 3 ? pal[3] : pal[Math.min(1, pal.length - 1)];
+ const cRight = pal.length > 3 ? pal[3] : pal[Math.max(0, pal.length - 1)];
let barIdx = 0;
for (const s of option.series) {
if (!s || s.type !== 'bar') continue;
@@ -1225,7 +1233,9 @@ export function ecApplyLayoutToSpec(
: new Map();
// 只对「需要上色」的 series 从 palette 取色,已设 color 的(如连接线、参考线)不占下标,使 Min/Max 等得到第 1、2 个颜色
- const colorableCount = option.series.filter((s: any) => s && s.itemStyle?.color == null).length;
+ const colorableCount = option.series.filter(
+ (s: any) => s && s.itemStyle?.color == null && !isCompanionSeries(s),
+ ).length;
const spacedIndices = useEvenSpacing && colorableCount > 0
? pickEvenlySpacedColorIndices(n, colorableCount)
: null;
@@ -1236,6 +1246,17 @@ export function ecApplyLayoutToSpec(
s.itemStyle = s.itemStyle || {};
if (s.itemStyle.color != null) return;
+ // An overlay companion (outliers / the raw sample) belongs to
+ // the series it sits on, so it inherits that colour instead
+ // of consuming a palette slot and landing a different hue.
+ if (isCompanionSeries(s)) {
+ const owner = option.series[idx - 1];
+ if (owner?.itemStyle?.color != null) {
+ s.itemStyle.color = owner.itemStyle.color;
+ return;
+ }
+ }
+
// Rank / Index 颜色映射:根据 rank 数值在连续色带上取色
if (isRankLikeColor && rankLegendColorMap.size > 0) {
const rawName: string = typeof s.name === 'string'
diff --git a/packages/flint-js/src/echarts/templates/boxplot.ts b/packages/flint-js/src/echarts/templates/boxplot.ts
index 91e3a438..3a2cee21 100644
--- a/packages/flint-js/src/echarts/templates/boxplot.ts
+++ b/packages/flint-js/src/echarts/templates/boxplot.ts
@@ -79,19 +79,44 @@ function findOutliers(values: number[]): number[] {
return values.filter(v => v < lo || v > hi);
}
-function boxplotLaneOffset(bandWidth: number, laneCount: number, laneIndex: number): number {
+/** Lane centre offset and box width, in pixels, within one category band. */
+function boxplotLaneGeometry(bandWidth: number, laneCount: number, laneIndex: 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);
+ return {
+ offset: boxWidth / 2 - availableWidth / 2 + laneIndex * (boxGap + boxWidth),
+ boxWidth,
+ };
+}
+
+// Half-width of the raw-observation jitter cloud, as a fraction of one box.
+const POINT_JITTER_FRACTION = 0.35;
+
+/**
+ * Deterministic jitter in [-1, 1]. A golden-ratio sequence spreads successive
+ * points evenly instead of clumping the way independent random draws do, and
+ * being deterministic keeps a re-render pixel-identical.
+ */
+function jitterAt(index: number): number {
+ return ((index * 0.6180339887498949) % 1) * 2 - 1;
}
-function makeOutlierSeries(
+/**
+ * Scatter overlay drawn on top of the boxes — either just the outliers, or
+ * every raw observation when `showPoints` is on.
+ *
+ * ECharts has no per-point band offset, so this is a `custom` series that
+ * resolves the lane (and the jitter carried as the datum's third element)
+ * against the live band width at render time.
+ */
+function makePointSeries(
name: string,
data: any[],
laneIndex: number,
laneCount: number,
horizontal: boolean,
+ fullSample = false,
): any {
return {
name,
@@ -100,23 +125,35 @@ function makeOutlierSeries(
data,
encode: { tooltip: [0, 1] },
z: 3,
+ // Marks this as an overlay belonging to the boxplot series before it, so
+ // palette assignment gives it that box's colour instead of a new slot.
+ _companion: true,
renderItem: (_params: any, api: any) => {
const category = Number(api.value(0));
const value = Number(api.value(1));
+ const jitter = Number(api.value(2)) || 0;
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);
+ const lane = boxplotLaneGeometry(bandWidth, laneCount, laneIndex);
+ const offset = lane.offset + jitter * lane.boxWidth * POINT_JITTER_FRACTION;
+ const color = api.visual('color');
return {
type: 'circle',
shape: {
cx: point[0] + (horizontal ? 0 : offset),
cy: point[1] + (horizontal ? offset : 0),
- r: 2,
+ r: fullSample ? Math.max(1.6, Math.min(3.2, lane.boxWidth * 0.08)) : 2,
},
- style: { fill: api.visual('color') },
+ // With the whole sample drawn the box goes hollow and the points
+ // become the mark that spends saturated ink, so they carry the
+ // group colour. Slight transparency lets dense regions read as
+ // density; the hairline halo keeps overlaps countable.
+ style: fullSample
+ ? { fill: color, opacity: 0.7, stroke: '#ffffff', lineWidth: 0.5 }
+ : { fill: color },
};
},
};
@@ -187,8 +224,11 @@ export const ecBoxplotDef: ChartTemplateDef = {
// whiskers, outliers are drawn as a scatter overlay unless suppressed.
const whiskerMethod: 'iqr' | 'minmax' =
ctx.chartProperties?.whiskerMethod === 'minmax' ? 'minmax' : 'iqr';
+ // `showPoints` overlays every raw observation instead — which makes the
+ // separate outlier marks redundant, since they are drawn too.
+ const showPoints = ctx.chartProperties?.showPoints === true;
const showOutliers =
- whiskerMethod === 'iqr' && ctx.chartProperties?.showOutliers !== false;
+ !showPoints && whiskerMethod === 'iqr' && ctx.chartProperties?.showOutliers !== false;
// Determine which axis is categorical and which is quantitative
const xIsDiscrete = isDiscrete(xCS.type);
@@ -274,7 +314,7 @@ export const ecBoxplotDef: ChartTemplateDef = {
const catGroups = groupBy(table, catField);
for (let lane = 0; lane < maxPerBand; lane++) {
const boxData: ({ value: [number, number, number, number, number]; itemStyle: any } | '-')[] = [];
- const outlierData: any[] = [];
+ const pointData: any[] = [];
for (let i = 0; i < categories.length; i++) {
const cat = categories[i];
const g = perBand.get(cat)?.[lane];
@@ -283,15 +323,24 @@ export const ecBoxplotDef: ChartTemplateDef = {
const values = rows.map((r: any) => Number(r[valField])).filter((v: number) => isFinite(v));
if (!values.length) { boxData.push('-'); continue; }
const c = colorFor(g);
- boxData.push({ value: fiveNumberSummary(values, whiskerMethod), itemStyle: { color: c, borderColor: c } });
- if (showOutliers) {
- for (const o of findOutliers(values)) outlierData.push({ value: [i, o], itemStyle: { color: c } });
+ boxData.push({
+ value: fiveNumberSummary(values, whiskerMethod),
+ // Hollow box when the sample is drawn (see makePointSeries).
+ itemStyle: { color: showPoints ? 'transparent' : c, borderColor: c },
+ });
+ if (showPoints) {
+ values.forEach((v, k) => pointData.push([i, v, jitterAt(k)]));
+ } else if (showOutliers) {
+ for (const o of findOutliers(values)) pointData.push({ value: [i, o], itemStyle: { color: c } });
}
}
- option.series.push({ name: `__lane${lane}`, type: 'boxplot', data: boxData });
- if (outlierData.length > 0) {
- option.series.push(makeOutlierSeries(
- `__lane${lane} (outliers)`, outlierData, lane, maxPerBand, isHorizontal,
+ option.series.push({
+ name: `__lane${lane}`, type: 'boxplot', data: boxData,
+ ...(showPoints ? { itemStyle: { borderWidth: 1.5 } } : {}),
+ });
+ if (pointData.length > 0) {
+ option.series.push(makePointSeries(
+ `__lane${lane} (points)`, pointData, lane, maxPerBand, isHorizontal, showPoints,
));
}
}
@@ -305,8 +354,8 @@ export const ecBoxplotDef: ChartTemplateDef = {
for (let cIdx = 0; cIdx < colorCategories.length; cIdx++) {
const colorName = colorCategories[cIdx];
- const boxData: ([number, number, number, number, number] | '-')[] = [];
- const outlierData: [number, number][] = [];
+ const boxData: any[] = [];
+ const pointData: number[][] = [];
for (let i = 0; i < categories.length; i++) {
const cat = categories[i];
@@ -319,11 +368,21 @@ export const ecBoxplotDef: ChartTemplateDef = {
// flat box at 0 in every unoccupied lane (the sparse-dodge
// zero-box bug). ECharts boxplot does not accept `null`
// data items; `'-'` is its missing-value sentinel.
- boxData.push(values.length ? fiveNumberSummary(values, whiskerMethod) : '-');
+ // A per-datum transparent fill hollows the box when the raw
+ // sample is drawn; the palette still owns the outline, which
+ // `instantiate-spec` assigns at series level.
+ boxData.push(
+ !values.length ? '-'
+ : showPoints
+ ? { value: fiveNumberSummary(values, whiskerMethod), itemStyle: { color: 'transparent' } }
+ : fiveNumberSummary(values, whiskerMethod),
+ );
- if (showOutliers) {
+ if (showPoints) {
+ values.forEach((v, k) => pointData.push([i, v, jitterAt(k)]));
+ } else if (showOutliers) {
for (const o of findOutliers(values)) {
- outlierData.push([i, o]);
+ pointData.push([i, o]);
}
}
}
@@ -332,11 +391,12 @@ export const ecBoxplotDef: ChartTemplateDef = {
name: colorName,
type: 'boxplot',
data: boxData,
+ ...(showPoints ? { itemStyle: { borderWidth: 1.5 } } : {}),
// itemStyle 由 ecApplyLayoutToSpec 按 colorDecisions 填充
});
- if (outlierData.length > 0) {
- option.series.push(makeOutlierSeries(
- colorName + ' (outliers)', outlierData, cIdx, colorCategories.length, isHorizontal,
+ if (pointData.length > 0) {
+ option.series.push(makePointSeries(
+ colorName + ' (points)', pointData, cIdx, colorCategories.length, isHorizontal, showPoints,
));
}
}
@@ -346,18 +406,21 @@ export const ecBoxplotDef: ChartTemplateDef = {
} else {
// Single boxplot series (no color grouping)
const catGroups = groupBy(table, catField);
- const boxData: [number, number, number, number, number][] = [];
- const outlierData: [number, number][] = [];
+ const boxData: any[] = [];
+ const pointData: number[][] = [];
for (let i = 0; i < categories.length; i++) {
const cat = categories[i];
const rows = catGroups.get(cat) || [];
const values = rows.map((r: any) => Number(r[valField])).filter((v: number) => isFinite(v));
- boxData.push(fiveNumberSummary(values, whiskerMethod));
+ const summary = fiveNumberSummary(values, whiskerMethod);
+ boxData.push(showPoints ? { value: summary, itemStyle: { color: 'transparent' } } : summary);
- if (showOutliers) {
+ if (showPoints) {
+ values.forEach((v, k) => pointData.push([i, v, jitterAt(k)]));
+ } else if (showOutliers) {
for (const o of findOutliers(values)) {
- outlierData.push([i, o]);
+ pointData.push([i, o]);
}
}
}
@@ -365,10 +428,11 @@ export const ecBoxplotDef: ChartTemplateDef = {
option.series.push({
type: 'boxplot',
data: boxData,
+ ...(showPoints ? { itemStyle: { borderWidth: 1.5 } } : {}),
// 单系列颜色由 ecApplyLayoutToSpec 使用 cat10[0] 等统一默认
});
- if (outlierData.length > 0) {
- option.series.push(makeOutlierSeries('Outliers', outlierData, 0, 1, isHorizontal));
+ if (pointData.length > 0) {
+ option.series.push(makePointSeries('Points', pointData, 0, 1, isHorizontal, showPoints));
}
}
@@ -385,9 +449,24 @@ export const ecBoxplotDef: ChartTemplateDef = {
],
defaultValue: 'iqr',
} as ChartPropertyDef,
+ {
+ key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false,
+ // Jitter needs a band to scatter within, so this is only meaningful
+ // once one position axis is discrete.
+ check: (ctx) => ({
+ applicable: isDiscrete(ctx.channelSemantics?.x?.type)
+ || isDiscrete(ctx.channelSemantics?.y?.type),
+ }),
+ } as ChartPropertyDef,
{
key: 'showOutliers', label: 'Outliers', type: 'binary', defaultValue: true,
- check: (ctx) => ({ applicable: ctx.chartProperties?.whiskerMethod !== 'minmax' }),
+ // Outliers exist only with Tukey whiskers; min–max whiskers absorb
+ // every point. And once every observation is drawn, the outlier
+ // marks are just duplicates.
+ check: (ctx) => ({
+ applicable: ctx.chartProperties?.whiskerMethod !== 'minmax'
+ && ctx.chartProperties?.showPoints !== true,
+ }),
} as ChartPropertyDef,
{
key: 'dodge', label: 'Dodge', type: 'discrete',
diff --git a/packages/flint-js/src/excel/artifact.ts b/packages/flint-js/src/excel/artifact.ts
index 728cf0ed..cb6ed958 100644
--- a/packages/flint-js/src/excel/artifact.ts
+++ b/packages/flint-js/src/excel/artifact.ts
@@ -95,7 +95,7 @@ export function prepareExcelArtifact(value: unknown): PreparedExcelArtifact {
rangeA1: `A1:${excelColumnLetter(columns - 1)}${rows}`,
chartType,
numericAxis: /XYScatter|Bubble/i.test(chartType),
- dateAxis: /Stock/i.test(chartType),
+ dateAxis: /Stock/i.test(chartType) || spec.categoryAxis?.categoryType === 'DateAxis',
hasAxes: !/Pie|Doughnut|Treemap|Sunburst|Funnel/i.test(chartType),
isBar: /Bar|Column/i.test(chartType),
isLine: /Line/i.test(chartType),
diff --git a/packages/flint-js/src/excel/assemble.ts b/packages/flint-js/src/excel/assemble.ts
index de1b538d..0b26b049 100644
--- a/packages/flint-js/src/excel/assemble.ts
+++ b/packages/flint-js/src/excel/assemble.ts
@@ -38,6 +38,7 @@ import type {
ExcelNativeSeriesSpec,
ExcelSeriesBy,
} from './types';
+import { excelDateAxis, excelDateSerial } from './date-axis';
type Cell = string | number | null;
@@ -84,11 +85,6 @@ function focusedNumericAxis(values: number[]): Partial labelBudget ? Math.ceil(categoryCount / labelBudget) : undefined;
-}
-
/** Normalize shorthand (`"x": "field"`) to `{ field }`. */
function normalizeEncodings(
raw: Record,
@@ -101,6 +97,22 @@ function normalizeEncodings(
return out;
}
+function applyFieldDisplayNames(
+ spec: ExcelChartSpec,
+ fieldDisplayNames: Record | undefined,
+): ExcelChartSpec {
+ if (!fieldDisplayNames) return spec;
+ const displayName = (value: string | number | null) =>
+ typeof value === 'string' ? fieldDisplayNames[value] ?? value : value;
+ if (spec.categoryAxis?.title) spec.categoryAxis.title = String(displayName(spec.categoryAxis.title));
+ if (spec.valueAxis?.title) spec.valueAxis.title = String(displayName(spec.valueAxis.title));
+ if (spec.data.length > 0) spec.data[0] = spec.data[0].map(displayName);
+ if (spec.series) {
+ for (const series of spec.series) series.name = String(displayName(series.name));
+ }
+ return spec;
+}
+
/** Distinct values of a field, first-seen order. */
function distinct(rows: any[], field: string): Cell[] {
const seen = new Set();
@@ -204,7 +216,10 @@ export function assembleExcel(input: ChartAssemblyInput): ExcelChartSpec {
}
if (chartTemplate.instantiate) {
- return chartTemplate.instantiate(templateContext);
+ return applyFieldDisplayNames(
+ chartTemplate.instantiate(templateContext),
+ input.field_display_names,
+ );
}
if (flintType === 'Bar Chart' || flintType === 'Grouped Bar Chart' || flintType === 'Stacked Bar Chart') {
@@ -486,7 +501,9 @@ export function assembleExcel(input: ChartAssemblyInput): ExcelChartSpec {
data.push([catField, ...seriesDescriptors.map((descriptor) => descriptor.label)]);
for (let categoryIndex = 0; categoryIndex < categories.length; categoryIndex += 1) {
data.push([
- String(categories[categoryIndex]),
+ typeOf(catCh!) === 'temporal'
+ ? excelDateSerial(categories[categoryIndex])
+ : String(categories[categoryIndex]),
...seriesValues.map((values) => values[categoryIndex]),
]);
}
@@ -544,11 +561,11 @@ export function assembleExcel(input: ChartAssemblyInput): ExcelChartSpec {
: {};
spec.categoryAxis = {
title: catField,
+ ...(orientation === 'vertical' && typeOf(catCh!) === 'temporal'
+ ? excelDateAxis(convertedData.map((row) => row[catField]), base.width)
+ : {}),
labelFontSize: orientation === 'horizontal' && data.length > 25
- ? Math.max(5, Math.min(10, ((base.height - 80) / (data.length - 1)) * 0.72))
- : undefined,
- tickLabelSpacing: orientation === 'vertical' && typeOf(catCh!) === 'temporal'
- ? temporalLabelSpacing(data.length - 1, base.width)
+ ? Math.max(8, Math.min(13, ((base.height - 80) / (data.length - 1)) * 0.9))
: undefined,
reversePlotOrder: orientation === 'horizontal' && typeOf(catCh!) !== 'temporal',
...numericXScale,
@@ -572,5 +589,5 @@ export function assembleExcel(input: ChartAssemblyInput): ExcelChartSpec {
spec.overlap = 0;
}
- return spec;
+ return applyFieldDisplayNames(spec, input.field_display_names);
}
diff --git a/packages/flint-js/src/excel/codegen.ts b/packages/flint-js/src/excel/codegen.ts
index 099f549c..0b8e0ec4 100644
--- a/packages/flint-js/src/excel/codegen.ts
+++ b/packages/flint-js/src/excel/codegen.ts
@@ -1,5 +1,12 @@
import { prepareExcelArtifact } from './artifact';
import type { ExcelAxisSpec, ExcelNativeChartSpec } from './types';
+import {
+ EXCEL_AXIS_TITLE_FONT_SIZE,
+ EXCEL_CHART_TITLE_FONT_SIZE,
+ EXCEL_DATA_LABEL_FONT_SIZE,
+ EXCEL_LABEL_FONT_SIZE,
+ EXCEL_LEGEND_FONT_SIZE,
+} from './typography';
export interface OfficeJsCodegenOptions {
scale?: number;
@@ -21,12 +28,16 @@ export interface GeneratedOfficeJs {
function axisCode(axisName: 'categoryAxis' | 'valueAxis', axis: ExcelAxisSpec): string[] {
const target = `chart.axes.${axisName}`;
const lines: string[] = [];
+ if (axis.categoryType) lines.push(` ${target}.categoryType = ${JSON.stringify(axis.categoryType)};`);
+ if (axis.baseTimeUnit) lines.push(` ${target}.baseTimeUnit = ${JSON.stringify(axis.baseTimeUnit)};`);
+ if (axis.majorTimeUnitScale) lines.push(` ${target}.majorTimeUnitScale = ${JSON.stringify(axis.majorTimeUnitScale)};`);
if (axis.title) {
lines.push(` ${target}.title.text = ${JSON.stringify(axis.title)};`);
lines.push(` ${target}.title.visible = true;`);
+ lines.push(` ${target}.title.format.font.size = ${EXCEL_AXIS_TITLE_FONT_SIZE};`);
}
if (axis.numberFormat) lines.push(` ${target}.numberFormat = ${JSON.stringify(axis.numberFormat)};`);
- if (axis.labelFontSize !== undefined) lines.push(` ${target}.format.font.size = ${axis.labelFontSize};`);
+ lines.push(` ${target}.format.font.size = ${axis.labelFontSize ?? EXCEL_LABEL_FONT_SIZE};`);
if (axis.tickLabelSpacing !== undefined) lines.push(` ${target}.tickLabelSpacing = ${axis.tickLabelSpacing};`);
if (axis.reversePlotOrder !== undefined) lines.push(` ${target}.reversePlotOrder = ${axis.reversePlotOrder};`);
if (axis.minimumScale !== undefined) lines.push(` ${target}.minimum = ${axis.minimumScale};`);
@@ -79,42 +90,47 @@ export function generateOfficeJs(value: unknown, options: OfficeJsCodegenOptions
);
if (spec.series?.length) {
- lines.push(" chart.series.load('items');", ' await context.sync();');
- lines.push(` if (chart.series.items.length < ${spec.series.length}) throw new Error('Excel inferred too few series.');`);
+ lines.push(
+ " chart.series.load('items');",
+ ' await context.sync();',
+ ' for (let index = chart.series.items.length - 1; index >= 0; index -= 1) {',
+ ' chart.series.getItemAt(index).delete();',
+ ' }',
+ ' await context.sync();',
+ );
spec.series.forEach((binding, index) => {
lines.push(
- ` chart.series.items[${index}].name = ${JSON.stringify(binding.name)};`,
- ` chart.series.items[${index}].setXAxisValues(sheet.getRangeByIndexes(1, ${binding.xColumn}, ${binding.rowCount}, 1));`,
- ` chart.series.items[${index}].setValues(sheet.getRangeByIndexes(1, ${binding.yColumn}, ${binding.rowCount}, 1));`,
+ ` const boundSeries${index} = chart.series.add(${JSON.stringify(binding.name)}, ${index});`,
+ ` boundSeries${index}.setXAxisValues(sheet.getRangeByIndexes(1, ${binding.xColumn}, ${binding.rowCount}, 1));`,
+ ` boundSeries${index}.setValues(sheet.getRangeByIndexes(1, ${binding.yColumn}, ${binding.rowCount}, 1));`,
);
if (binding.bubbleSizeColumn !== undefined) {
- lines.push(` chart.series.items[${index}].setBubbleSizes(sheet.getRangeByIndexes(1, ${binding.bubbleSizeColumn}, ${binding.rowCount}, 1));`);
+ lines.push(` boundSeries${index}.setBubbleSizes(sheet.getRangeByIndexes(1, ${binding.bubbleSizeColumn}, ${binding.rowCount}, 1));`);
}
});
- lines.push(
- ` for (let index = chart.series.items.length - 1; index >= ${spec.series.length}; index -= 1) {`,
- ' chart.series.getItemAt(index).delete();',
- ' await context.sync();',
- ' }',
- );
}
lines.push(` chart.width = ${width};`, ` chart.height = ${height};`);
if (spec.title) {
- lines.push(` chart.title.text = ${JSON.stringify(spec.title)};`, ' chart.title.visible = true;');
+ lines.push(
+ ` chart.title.text = ${JSON.stringify(spec.title)};`,
+ ' chart.title.visible = true;',
+ ` chart.title.format.font.size = ${EXCEL_CHART_TITLE_FONT_SIZE};`,
+ );
}
if (spec.legend) {
lines.push(` chart.legend.visible = ${spec.legend.visible};`);
if (spec.legend.visible && spec.legend.position) {
lines.push(` chart.legend.position = ${JSON.stringify(spec.legend.position)};`);
}
+ if (spec.legend.visible) lines.push(` chart.legend.format.font.size = ${EXCEL_LEGEND_FONT_SIZE};`);
}
if (spec.dataLabels) {
lines.push(` chart.dataLabels.visible = ${spec.dataLabels.visible};`);
if (spec.dataLabels.position) lines.push(` chart.dataLabels.position = ${JSON.stringify(spec.dataLabels.position)};`);
if (spec.dataLabels.numberFormat) lines.push(` chart.dataLabels.numberFormat = ${JSON.stringify(spec.dataLabels.numberFormat)};`);
if (spec.dataLabels.fontColor) lines.push(` chart.dataLabels.format.font.color = ${JSON.stringify(spec.dataLabels.fontColor)};`);
- if (spec.dataLabels.fontSize !== undefined) lines.push(` chart.dataLabels.format.font.size = ${spec.dataLabels.fontSize};`);
+ lines.push(` chart.dataLabels.format.font.size = ${spec.dataLabels.fontSize ?? EXCEL_DATA_LABEL_FONT_SIZE};`);
}
if (prepared.hasAxes) {
if (spec.categoryAxis) lines.push(...axisCode('categoryAxis', spec.categoryAxis));
diff --git a/packages/flint-js/src/excel/date-axis.ts b/packages/flint-js/src/excel/date-axis.ts
new file mode 100644
index 00000000..3b4d3336
--- /dev/null
+++ b/packages/flint-js/src/excel/date-axis.ts
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { ExcelAxisSpec } from './types';
+
+const EXCEL_EPOCH = Date.UTC(1899, 11, 30);
+const DAY_MILLISECONDS = 24 * 60 * 60 * 1000;
+
+export function excelDateSerial(value: unknown): number {
+ return (new Date(value as string | number | Date).getTime() - EXCEL_EPOCH) / DAY_MILLISECONDS;
+}
+
+function niceInterval(value: number): number {
+ const power = 10 ** Math.floor(Math.log10(value));
+ const fraction = value / power;
+ return (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10) * power;
+}
+
+export function excelDateAxis(values: unknown[], width: number): Pick<
+ ExcelAxisSpec,
+ 'categoryType' | 'baseTimeUnit' | 'majorUnit' | 'majorTimeUnitScale' | 'numberFormat'
+> {
+ const axis: ExcelAxisSpec = {
+ categoryType: 'DateAxis',
+ baseTimeUnit: 'Days',
+ numberFormat: 'yyyy-mm-dd',
+ };
+ const labelBudget = Math.max(2, Math.floor((width - 90) / 75));
+ if (values.length <= labelBudget) return axis;
+
+ const times = values
+ .map((value) => new Date(value as string | number | Date).getTime())
+ .filter(Number.isFinite);
+ if (times.length < 2) return axis;
+ const roughDays = Math.max(1, Math.max(...times) - Math.min(...times)) / DAY_MILLISECONDS / labelBudget;
+ if (roughDays >= 365) {
+ return { ...axis, majorUnit: niceInterval(roughDays / 365), majorTimeUnitScale: 'Years' };
+ }
+ if (roughDays >= 28) {
+ return { ...axis, majorUnit: niceInterval(roughDays / (365 / 12)), majorTimeUnitScale: 'Months' };
+ }
+ return { ...axis, majorUnit: niceInterval(roughDays), majorTimeUnitScale: 'Days' };
+}
diff --git a/packages/flint-js/src/excel/runtime.ts b/packages/flint-js/src/excel/runtime.ts
index 82603ca2..6b7e4ed2 100644
--- a/packages/flint-js/src/excel/runtime.ts
+++ b/packages/flint-js/src/excel/runtime.ts
@@ -1,4 +1,11 @@
import { prepareExcelArtifact, type PreparedExcelArtifact } from './artifact';
+import {
+ EXCEL_AXIS_TITLE_FONT_SIZE,
+ EXCEL_CHART_TITLE_FONT_SIZE,
+ EXCEL_DATA_LABEL_FONT_SIZE,
+ EXCEL_LABEL_FONT_SIZE,
+ EXCEL_LEGEND_FONT_SIZE,
+} from './typography';
export interface OfficeJsExcelApi {
run(callback: (context: any) => Promise): Promise;
@@ -16,11 +23,12 @@ export interface ExcelRenderResult {
inspection: unknown | null;
}
-function applyChartFormat(chart: any, prepared: PreparedExcelArtifact): void {
+function applyChartFormat(chart: any, prepared: PreparedExcelArtifact, nativeSeriesCount: number): void {
const { spec } = prepared;
if (spec.legend) {
chart.legend.visible = spec.legend.visible;
if (spec.legend.visible && spec.legend.position) chart.legend.position = spec.legend.position;
+ if (spec.legend.visible) chart.legend.format.font.size = EXCEL_LEGEND_FONT_SIZE;
}
if (prepared.hasAxes) {
const axes = [
@@ -29,12 +37,16 @@ function applyChartFormat(chart: any, prepared: PreparedExcelArtifact): void {
] as const;
for (const [axis, format] of axes) {
if (!format) continue;
+ if (format.categoryType) axis.categoryType = format.categoryType;
+ if (format.baseTimeUnit) axis.baseTimeUnit = format.baseTimeUnit;
+ if (format.majorTimeUnitScale) axis.majorTimeUnitScale = format.majorTimeUnitScale;
if (format.title) {
axis.title.text = format.title;
axis.title.visible = true;
+ axis.title.format.font.size = EXCEL_AXIS_TITLE_FONT_SIZE;
}
if (format.numberFormat) axis.numberFormat = format.numberFormat;
- if (format.labelFontSize !== undefined) axis.format.font.size = format.labelFontSize;
+ axis.format.font.size = format.labelFontSize ?? EXCEL_LABEL_FONT_SIZE;
if (format.tickLabelSpacing !== undefined) axis.tickLabelSpacing = format.tickLabelSpacing;
if (format.reversePlotOrder !== undefined) axis.reversePlotOrder = format.reversePlotOrder;
if (format.minimumScale !== undefined) axis.minimum = format.minimumScale;
@@ -47,9 +59,9 @@ function applyChartFormat(chart: any, prepared: PreparedExcelArtifact): void {
if (spec.dataLabels.position) chart.dataLabels.position = spec.dataLabels.position;
if (spec.dataLabels.numberFormat) chart.dataLabels.numberFormat = spec.dataLabels.numberFormat;
if (spec.dataLabels.fontColor) chart.dataLabels.format.font.color = spec.dataLabels.fontColor;
- if (spec.dataLabels.fontSize !== undefined) chart.dataLabels.format.font.size = spec.dataLabels.fontSize;
+ chart.dataLabels.format.font.size = spec.dataLabels.fontSize ?? EXCEL_DATA_LABEL_FONT_SIZE;
}
- for (let index = 0; index < prepared.seriesCount; index += 1) {
+ for (let index = 0; index < Math.min(prepared.seriesCount, nativeSeriesCount); index += 1) {
const series = chart.series.getItemAt(index);
const format = spec.seriesFormats?.[index];
if (format?.color) {
@@ -77,15 +89,14 @@ async function inspectChart(context: any, sheet: any, dataRange: any, chart: any
for (const series of chart.series.items) {
series.binOptions.load('type,count,width,allowOverflow,overflowValue,allowUnderflow,underflowValue');
}
- const primaryCategoryAxis = chart.axes.getItemOrNullObject('Category', 'Primary');
- const primaryValueAxis = chart.axes.getItemOrNullObject('Value', 'Primary');
- const secondaryValueAxis = chart.axes.getItemOrNullObject('Value', 'Secondary');
- for (const axis of [primaryCategoryAxis, primaryValueAxis, secondaryValueAxis]) {
- axis.load('isNullObject,axisType,axisGroup,visible,minimum,maximum,numberFormat');
+ const primaryCategoryAxis = chart.axes.categoryAxis;
+ const primaryValueAxis = chart.axes.valueAxis;
+ for (const axis of [primaryCategoryAxis, primaryValueAxis]) {
+ axis.load('axisType,axisGroup,visible,minimum,maximum,numberFormat');
axis.title.load('text,visible');
}
await context.sync();
- const describeAxis = (axis: any) => axis.isNullObject ? null : { ...axis.toJSON(), title: axis.title.toJSON() };
+ const describeAxis = (axis: any) => ({ ...axis.toJSON(), title: axis.title.toJSON() });
return {
sourceRange: {
address: dataRange.address,
@@ -106,7 +117,6 @@ async function inspectChart(context: any, sheet: any, dataRange: any, chart: any
axes: {
primaryCategory: describeAxis(primaryCategoryAxis),
primaryValue: describeAxis(primaryValueAxis),
- secondaryValue: describeAxis(secondaryValueAxis),
},
},
};
@@ -153,22 +163,18 @@ export async function renderExcelChart(
if (spec.series?.length) {
chart.series.load('items');
await context.sync();
- if (chart.series.items.length < spec.series.length) {
- throw new Error(`Excel inferred ${chart.series.items.length} series; ${spec.series.length} required.`);
+ for (let index = chart.series.items.length - 1; index >= 0; index -= 1) {
+ chart.series.getItemAt(index).delete();
}
+ await context.sync();
for (const [index, binding] of spec.series.entries()) {
- const series = chart.series.items[index];
- series.name = binding.name;
+ const series = chart.series.add(binding.name, index);
series.setXAxisValues(sheet.getRangeByIndexes(1, binding.xColumn, binding.rowCount, 1));
series.setValues(sheet.getRangeByIndexes(1, binding.yColumn, binding.rowCount, 1));
if (binding.bubbleSizeColumn !== undefined) {
series.setBubbleSizes(sheet.getRangeByIndexes(1, binding.bubbleSizeColumn, binding.rowCount, 1));
}
}
- for (let index = chart.series.items.length - 1; index >= spec.series.length; index -= 1) {
- chart.series.getItemAt(index).delete();
- await context.sync();
- }
}
const width = spec.width ?? 400;
@@ -178,8 +184,11 @@ export async function renderExcelChart(
if (spec.title) {
chart.title.text = spec.title;
chart.title.visible = true;
+ chart.title.format.font.size = EXCEL_CHART_TITLE_FONT_SIZE;
}
- applyChartFormat(chart, prepared);
+ chart.series.load?.('items');
+ await context.sync();
+ applyChartFormat(chart, prepared, chart.series.items?.length ?? prepared.seriesCount);
await context.sync();
const image = chart.getImage(
Math.round(width * (96 / 72) * scale),
diff --git a/packages/flint-js/src/excel/templates/candlestick.ts b/packages/flint-js/src/excel/templates/candlestick.ts
index fada1375..9d8e67a4 100644
--- a/packages/flint-js/src/excel/templates/candlestick.ts
+++ b/packages/flint-js/src/excel/templates/candlestick.ts
@@ -2,15 +2,10 @@
// Licensed under the MIT License.
import { formatSpecToExcel } from '../chart-types';
+import { excelDateAxis, excelDateSerial } from '../date-axis';
import type { ExcelTemplateDef } from './types';
const PRICE_CHANNELS = ['open', 'high', 'low', 'close'] as const;
-const EXCEL_EPOCH = Date.UTC(1899, 11, 30);
-const DAY_MILLISECONDS = 24 * 60 * 60 * 1000;
-
-function excelDateSerial(value: unknown): number {
- return (new Date(value as string | number | Date).getTime() - EXCEL_EPOCH) / DAY_MILLISECONDS;
-}
function niceStep(span: number): number {
const rough = span / 5;
@@ -66,8 +61,6 @@ export const excelCandlestickDef: ExcelTemplateDef = {
const xField = fieldOf('x')!;
const fields = PRICE_CHANNELS.map((channel) => fieldOf(channel)!);
const base = input.chart_spec.baseSize ?? { width: 480, height: 320 };
- const labelBudget = Math.max(12, Math.floor((base.width - 90) / 14));
- const tickLabelSpacing = table.length > labelBudget ? Math.ceil(table.length / labelBudget) : undefined;
const lows = table.map((row) => Number(row[fields[2]]));
const highs = table.map((row) => Number(row[fields[1]]));
const minimum = Math.min(...lows);
@@ -89,7 +82,10 @@ export const excelCandlestickDef: ExcelTemplateDef = {
...fields.map((field) => Number(row[field])),
]),
],
- categoryAxis: { title: xField, numberFormat: 'yyyy-mm-dd', tickLabelSpacing },
+ categoryAxis: {
+ title: xField,
+ ...excelDateAxis(table.map((row) => row[xField]), base.width),
+ },
valueAxis: {
title: 'Price',
numberFormat: formatSpecToExcel(semantics.close?.format),
diff --git a/packages/flint-js/src/excel/templates/funnel.ts b/packages/flint-js/src/excel/templates/funnel.ts
index 401037de..34cabd5e 100644
--- a/packages/flint-js/src/excel/templates/funnel.ts
+++ b/packages/flint-js/src/excel/templates/funnel.ts
@@ -51,7 +51,7 @@ export const excelFunnelChartDef: ExcelTemplateDef = {
visible: true,
numberFormat: formatSpecToExcel(semantics.size?.format),
fontColor: '#FFFFFF',
- fontSize: 11,
+ fontSize: 13,
},
seriesFormats: [{ color: '#4472C4' }],
width: base.width,
diff --git a/packages/flint-js/src/excel/templates/histogram.ts b/packages/flint-js/src/excel/templates/histogram.ts
index bf6964eb..8515ead2 100644
--- a/packages/flint-js/src/excel/templates/histogram.ts
+++ b/packages/flint-js/src/excel/templates/histogram.ts
@@ -57,20 +57,31 @@ export const excelHistogramDef: ExcelTemplateDef = {
if (seriesIndex >= 0) seriesCounts[seriesIndex][binIndex] += 1;
}
const base = input.chart_spec.baseSize ?? { width: 480, height: 320 };
+ const data = colorField
+ ? [
+ [valueField, labels[0], '', ...labels.slice(1)],
+ ...seriesKeys.map((name, seriesIndex) => [
+ name,
+ seriesCounts[seriesIndex][0],
+ 0,
+ ...seriesCounts[seriesIndex].slice(1),
+ ]),
+ ]
+ : [
+ [valueField, ...seriesKeys],
+ ...labels.map((label, index) => [label, seriesCounts[0][index]]),
+ ];
return {
schema: 'flint.excel.chart/v1',
kind: 'chart',
chartType: colorField ? 'ColumnStacked' : 'ColumnClustered',
title: `Distribution of ${valueField}`,
- seriesBy: 'Columns',
- data: [
- [valueField, ...seriesKeys],
- ...labels.map((label, index) => [label, ...seriesCounts.map((series) => series[index])]),
- ],
+ seriesBy: colorField ? 'Rows' : 'Columns',
+ data,
categoryAxis: { title: valueField },
valueAxis: { title: 'Count', numberFormat: '0' },
legend: { visible: Boolean(colorField), position: 'Bottom' },
- gapWidth: 0,
+ gapWidth: 20,
width: base.width,
height: base.height,
warnings: [],
diff --git a/packages/flint-js/src/excel/types.ts b/packages/flint-js/src/excel/types.ts
index 8d236eb5..42ef4779 100644
--- a/packages/flint-js/src/excel/types.ts
+++ b/packages/flint-js/src/excel/types.ts
@@ -24,6 +24,12 @@ export type ExcelLegendPosition = 'Top' | 'Bottom' | 'Left' | 'Right';
export interface ExcelAxisSpec {
/** Axis title text (from the field display name). */
title?: string;
+ /** Native Excel category-axis interpretation. */
+ categoryType?: 'DateAxis' | 'TextAxis';
+ /** Base unit used by a native Excel date axis. */
+ baseTimeUnit?: 'Days' | 'Months' | 'Years';
+ /** Unit associated with `majorUnit` on a native Excel date axis. */
+ majorTimeUnitScale?: 'Days' | 'Months' | 'Years';
/** Axis-label font size in points, used when dense categories need explicit sizing. */
labelFontSize?: number;
/** Number of categories between displayed labels; underlying data stays intact. */
diff --git a/packages/flint-js/src/excel/typography.ts b/packages/flint-js/src/excel/typography.ts
new file mode 100644
index 00000000..314cb72f
--- /dev/null
+++ b/packages/flint-js/src/excel/typography.ts
@@ -0,0 +1,5 @@
+export const EXCEL_CHART_TITLE_FONT_SIZE = 18;
+export const EXCEL_AXIS_TITLE_FONT_SIZE = 15;
+export const EXCEL_LABEL_FONT_SIZE = 13;
+export const EXCEL_LEGEND_FONT_SIZE = 13;
+export const EXCEL_DATA_LABEL_FONT_SIZE = 13;
\ No newline at end of file
diff --git a/packages/flint-js/src/plotly/assemble.ts b/packages/flint-js/src/plotly/assemble.ts
index 0f18d537..1a1c804a 100644
--- a/packages/flint-js/src/plotly/assemble.ts
+++ b/packages/flint-js/src/plotly/assemble.ts
@@ -65,6 +65,28 @@ import { normalizeChartProperties } from '../core/normalize-properties';
*
* @returns A Plotly figure with optional `_warnings` and `_width`/`_height` hints
*/
+function applyFieldDisplayNames(figure: any, names: Record | undefined): void {
+ if (!names) return;
+ const displayName = (value: unknown) => typeof value === 'string' ? names[value] ?? value : value;
+ for (const [key, axis] of Object.entries(figure.layout ?? {}) as Array<[string, any]>) {
+ if (/^[xy]axis\d*$/.test(key) && axis?.title?.text) {
+ axis.title.text = displayName(axis.title.text);
+ }
+ }
+ if (figure.layout?.legend?.title?.text) {
+ figure.layout.legend.title.text = displayName(figure.layout.legend.title.text);
+ }
+ for (const trace of figure.data ?? []) {
+ if (trace?.name) trace.name = displayName(trace.name);
+ if (trace?.marker?.colorbar?.title?.text) {
+ trace.marker.colorbar.title.text = displayName(trace.marker.colorbar.title.text);
+ }
+ if (trace?.colorbar?.title?.text) {
+ trace.colorbar.title.text = displayName(trace.colorbar.title.text);
+ }
+ }
+}
+
export function assemblePlotly(input: ChartAssemblyInput): any {
const chartType = input.chart_spec.chartType;
const semanticTypes = input.semantic_types ?? {};
@@ -416,6 +438,8 @@ export function assemblePlotly(input: ChartAssemblyInput): any {
figure._pivot = legacyPivot.surface;
}
+ applyFieldDisplayNames(figure, input.field_display_names);
+
return figure;
}
diff --git a/packages/flint-js/src/plotly/templates/boxplot.ts b/packages/flint-js/src/plotly/templates/boxplot.ts
index dbd5904e..d070799f 100644
--- a/packages/flint-js/src/plotly/templates/boxplot.ts
+++ b/packages/flint-js/src/plotly/templates/boxplot.ts
@@ -55,20 +55,35 @@ export const plBoxplotDef: ChartTemplateDef = {
const isHorizontal = catAxis === 'y';
const categories = extractCategories(table, catField, channelSemantics[catAxis]?.ordinalSortOrder);
+ // `showPoints` overlays every raw observation on the box; that makes the
+ // separate outlier marks redundant, since those points are drawn too.
+ const showPoints = chartProperties?.showPoints === true;
const showOutliers = chartProperties?.showOutliers !== false;
- const boxpoints = showOutliers ? 'outliers' : false;
+ const boxpoints = showPoints ? 'all' : (showOutliers ? 'outliers' : false);
const palette = getPlotlyPalette(ctx, 'color');
const makeTrace = (name: string | undefined, rows: any[], colorIdx: number) => {
const cats = rows.map((r: any) => String(r[catField] ?? ''));
const vals = rows.map((r: any) => Number(r[valField]));
+ const seriesColor = getSeriesColor(palette, colorIdx);
return {
type: 'box',
...(name != null ? { name } : {}),
...(isHorizontal ? { y: cats, x: vals } : { x: cats, y: vals }),
boxpoints,
- marker: { color: getSeriesColor(palette, colorIdx), size: 3 },
- line: { color: getSeriesColor(palette, colorIdx) },
+ // Drawing the sample inverts the visual hierarchy: the sample
+ // becomes the figure and the box demotes to scaffolding over it.
+ // So the box goes hollow and keeps only its outline, and the
+ // group colour lives on the points — fill *and* point colour
+ // would encode the group twice. `pointpos: 0` centres the cloud
+ // on the box instead of parking it alongside.
+ ...(showPoints
+ ? { jitter: 0.6, pointpos: 0, fillcolor: 'rgba(0,0,0,0)' }
+ : {}),
+ marker: showPoints
+ ? { color: seriesColor, size: 4, opacity: 0.7, line: { color: '#ffffff', width: 0.5 } }
+ : { color: seriesColor, size: 3 },
+ line: { color: seriesColor, ...(showPoints ? { width: 1.5 } : {}) },
};
};
@@ -108,6 +123,19 @@ export const plBoxplotDef: ChartTemplateDef = {
delete spec.encoding;
},
properties: [
- { key: 'showOutliers', label: 'Outliers', type: 'binary', defaultValue: true } as ChartPropertyDef,
+ {
+ key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false,
+ // Jitter needs a band to scatter within, so this is only meaningful
+ // once one position axis is discrete.
+ check: (ctx) => ({
+ applicable: isDiscreteType(ctx.channelSemantics?.x?.type)
+ || isDiscreteType(ctx.channelSemantics?.y?.type),
+ }),
+ } as ChartPropertyDef,
+ {
+ key: 'showOutliers', label: 'Outliers', type: 'binary', defaultValue: true,
+ // Once every observation is drawn, the outlier marks are duplicates.
+ check: (ctx) => ({ applicable: ctx.chartProperties?.showPoints !== true }),
+ } as ChartPropertyDef,
],
};
diff --git a/packages/flint-js/src/test-data/gantt-bullet-tests.ts b/packages/flint-js/src/test-data/gantt-bullet-tests.ts
index 34d9a210..950aadbe 100644
--- a/packages/flint-js/src/test-data/gantt-bullet-tests.ts
+++ b/packages/flint-js/src/test-data/gantt-bullet-tests.ts
@@ -190,3 +190,66 @@ export function genBulletTests(): TestCase[] {
...realBulletCases(),
];
}
+
+// ---------------------------------------------------------------------------
+// KPI Card — big-number tiles, each measured against its own goal
+// ---------------------------------------------------------------------------
+
+/** metric, value, goal — chosen to land one tile in each verdict state. */
+const QUARTER_KPIS: Array<[string, number, number]> = [
+ ['Revenue ($k)', 1284, 1200], // exceeded — at or above goal
+ ['New customers', 372, 500], // on track — between the two
+ ['Churn saves', 41, 120], // behind — well short of goal
+ ['NPS', 54, 50], // exceeded
+];
+
+const ADOPTION_KPIS: Array<[string, number, number]> = [
+ ['Weekly actives (k)', 88, 120],
+ ['Seats licensed (k)', 143, 140],
+];
+
+export function genKpiCardTests(): TestCase[] {
+ const quarter = QUARTER_KPIS.map(([metric, value, goal]) => ({ metric, value, goal }));
+ const adoption = ADOPTION_KPIS.map(([metric, value, goal]) => ({ metric, value, goal }));
+ const meta = {
+ metric: { type: Type.String, semanticType: 'Category', levels: [] },
+ value: { type: Type.Number, semanticType: 'Quantity', levels: [] },
+ goal: { type: Type.Number, semanticType: 'Quantity', levels: [] },
+ };
+ const encodingMap = {
+ metric: makeEncodingItem('metric'),
+ value: makeEncodingItem('value'),
+ goal: makeEncodingItem('goal'),
+ };
+ const fields = [makeField('metric'), makeField('value'), makeField('goal')];
+ return [
+ {
+ title: 'Quarterly KPIs vs goal',
+ description:
+ 'Four big-number tiles, each with a progress bar against its own '
+ + 'goal. The four deliberately span every verdict the card can '
+ + 'reach — two that beat their goal, one still in progress and one '
+ + 'well short — so a theme\u2019s accent and its status inks all '
+ + 'appear on a single sheet and can be told apart.',
+ tags: ['kpi', 'card', 'big-number', 'target', 'gallery'],
+ chartType: 'KPI Card',
+ data: quarter,
+ fields,
+ metadata: meta,
+ encodingMap,
+ },
+ {
+ title: 'Adoption against plan',
+ description:
+ 'A two-tile card: one metric short of plan and one just past it, '
+ + 'at the width where the tiles are widest and the big number has '
+ + 'the most room.',
+ tags: ['kpi', 'card', 'big-number', 'target', 'gallery'],
+ chartType: 'KPI Card',
+ data: adoption,
+ fields,
+ metadata: meta,
+ encodingMap,
+ },
+ ];
+}
diff --git a/packages/flint-js/src/test-data/index.ts b/packages/flint-js/src/test-data/index.ts
index 841f0f6f..e59fc3ad 100644
--- a/packages/flint-js/src/test-data/index.ts
+++ b/packages/flint-js/src/test-data/index.ts
@@ -45,7 +45,7 @@ export { genDiscreteAxisTests } from './discrete-axis-tests';
export { genDateTests, genDateYearTests, genDateMonthTests, genDateYearMonthTests, genDateDecadeTests, genDateDateTimeTests, genDateHoursTests } from './date-tests';
export { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests';
export { genMapTests, genChoroplethTests } from './map-tests';
-export { genGanttTests, genBulletTests } from './gantt-bullet-tests';
+export { genGanttTests, genBulletTests, genKpiCardTests } from './gantt-bullet-tests';
export {
OMNI_VIZ_ROWS,
OMNI_VIZ_LEVELS,
@@ -92,7 +92,7 @@ import { genHistogramTests, genBoxplotTests, genDensityTests, genStripPlotTests
import { genDensityContourTests } from './density-2d-tests';
import { genViolinTests } from './violin-tests';
import { genMapTests, genChoroplethTests } from './map-tests';
-import { genGanttTests, genBulletTests } from './gantt-bullet-tests';
+import { genGanttTests, genBulletTests, genKpiCardTests } from './gantt-bullet-tests';
import { genLineTests } from './line-tests';
import { genSparklineTests } from './sparkline-tests';
import { genBumpChartTests } from './line-area-tests';
@@ -180,6 +180,7 @@ export const TEST_GENERATORS: Record TestCase[]> = {
'Choropleth': genChoroplethTests,
'Gantt Chart': genGanttTests,
'Bullet Chart': genBulletTests,
+ 'KPI Card': genKpiCardTests,
'Facet: Columns': genFacetColumnTests,
'Facet: Rows': genFacetRowTests,
'Facet: Cols+Rows': genFacetColRowTests,
diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts
index 69e6ac0c..eb5a1c70 100644
--- a/packages/flint-js/src/vegalite/assemble.ts
+++ b/packages/flint-js/src/vegalite/assemble.ts
@@ -66,6 +66,9 @@ import { computeLayout, computeChannelBudgets, computeMinSubplotDimensions, deri
import { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec';
import { normalizeStaticSeries } from '../core/static-series';
import { normalizeChartProperties } from '../core/normalize-properties';
+import { groundTheme, resolveChartDefaults, resolveCompileDefaults } from '../core/theme/ground';
+import { resolveThemeSpec } from '../core/theme/presets';
+import { realizeThemeVegaLite, realizeValueLabelsVegaLite, collectMarkTypes, collectPositional } from './theme';
// ---------------------------------------------------------------------------
// Helpers
@@ -109,14 +112,21 @@ const escapeVlFieldName = (name: string): string =>
export function assembleVegaLite(input: ChartAssemblyInput): any {
const chartType = input.chart_spec.chartType;
const semanticTypes = input.semantic_types ?? {};
+ // `theme_spec` may name a house Flint ships rather than spell one out.
+ const themeSpec = resolveThemeSpec(input.theme_spec);
+ // A house may prefer a size, a stretch budget, a facet gap. Those settle
+ // before anything is measured, and in a fixed order: the chart spec first,
+ // the theme's presets under it, flint's own defaults under that.
+ const themePresets = resolveCompileDefaults(themeSpec, input.options);
// Internal layout targets the base (target) size; the optional canvasSize
// ceiling is applied as per-dimension stretch caps once options resolve.
// The base is clamped to the ceiling so a smaller canvasSize shrinks the
// chart to fit rather than overflowing it.
- const sizeCeiling = input.chart_spec.canvasSize;
- const baseSize = resolveBaseSize(input.chart_spec.baseSize, sizeCeiling);
+ const housePresets = themeSpec?.compileDefaults;
+ const sizeCeiling = input.chart_spec.canvasSize ?? housePresets?.canvasSize;
+ const baseSize = resolveBaseSize(input.chart_spec.baseSize ?? housePresets?.baseSize, sizeCeiling);
const canvasSize = baseSize;
- const options = input.options ?? {};
+ const options = themePresets.options ?? {};
let chartTemplate = vlGetTemplateDef(chartType) as ChartTemplateDef;
if (!chartTemplate) {
throw new Error(`Unknown chart type: ${chartType}`);
@@ -131,9 +141,32 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
const normalizedProps = normalizeChartProperties(
chartTemplate.properties, input.chart_spec.chartProperties,
);
- const chartProperties = normalizedProps.chartProperties;
+ let chartProperties = normalizedProps.chartProperties;
warnings.push(...normalizedProps.warnings);
+ // A house's rules about the chart itself — points on a line, a bump chart
+ // left unsmoothed — change what is drawn, not how it is dressed, so they
+ // are folded in here, before the pipeline reads the properties. Anything
+ // the caller stated already is left alone.
+ if (themeSpec && !chartProperties) chartProperties = {};
+ const chartDefaultsReport = chartProperties
+ ? resolveChartDefaults(
+ themeSpec, chartType, chartTemplate.properties,
+ input.chart_spec.chartProperties, chartProperties,
+ )
+ : [];
+
+ // One toggle, whichever way the template draws the numbers. A few charts
+ // print their own value labels instead of going through the theme's
+ // data-label layer. They expose the same `showValueLabels` control as every
+ // other chart; translating it to the older internal `showTextLabels`
+ // boolean keeps saved inputs compatible without publishing two controls.
+ // Silence stays silent: with no answer the template keeps its own default.
+ if (chartProperties && templateOwnsValueLabels(chartTemplate)) {
+ const choice = resolveValueLabelChoice(chartProperties);
+ if (choice) chartProperties.showTextLabels = choice === 'on';
+ }
+
// ═══════════════════════════════════════════════════════════════════════
// PRE-PHASE: Static Series Normalization
// ═══════════════════════════════════════════════════════════════════════
@@ -347,6 +380,42 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
...(declaration.paramOverrides || {}),
};
+ // How much room one category gets is a house decision — a journal that
+ // prints three wide boxes and a dashboard that prints thirty thin bars are
+ // both right about their own page. A template's band size is a guess made
+ // without knowing the house, so the house's number replaces it; a caller
+ // who states one outranks both.
+ //
+ // But a band step is a statement about bar thickness, and where *both* axes
+ // are banded there are no bars: the marks are cells, and a cell's size is
+ // fixed by the two counts and the room they share. A house that asks for
+ // 80px categories would print a grid of stripes. That one the layout keeps.
+ const houseBandStep = themeSpec?.layout?.bandStep;
+ const cellGrid = declaration.axisFlags?.x?.banded === true
+ && declaration.axisFlags?.y?.banded === true;
+ // A template may state a floor its read cannot go below (a slopegraph needs
+ // its two columns spread wide however compact the house). The house sets
+ // the band step, but not below that floor.
+ const minBandStep = (declaration.paramOverrides as AssembleOptions | undefined)?.minBandStep;
+ if (houseBandStep && options.defaultBandSize == null && !cellGrid) {
+ const step = minBandStep ? Math.max(houseBandStep, minBandStep) : houseBandStep;
+ effectiveOptions.defaultBandSize = step;
+ effectiveOptions.maxBandSize = Math.max(step, effectiveOptions.maxBandSize ?? 0);
+ chartDefaultsReport.push({
+ stage: 'ground',
+ path: 'layout.bandStep',
+ message: minBandStep && step > houseBandStep
+ ? `the house gives each category ${houseBandStep}px, but this chart reads only above ${minBandStep}px — held to ${step}px`
+ : `the house gives each category ${houseBandStep}px`,
+ });
+ } else if (houseBandStep && cellGrid) {
+ chartDefaultsReport.push({
+ stage: 'ground',
+ path: 'layout.bandStep',
+ message: `the house asks for ${houseBandStep}px categories, but both axes are banded — the marks are cells, whose size the grid settles, not the house`,
+ });
+ }
+
const {
addTooltips: addTooltipsOpt = false,
minSubplotSize: minSubplotVal = 60,
@@ -658,11 +727,88 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
vlApplyTooltips(vgObj);
}
+ // ═══════════════════════════════════════════════════════════════════════
+ // HEADLINE
+ // ═══════════════════════════════════════════════════════════════════════
+ //
+ // Written before theming, because whether the chart has a headline is a
+ // fact the theme reasons about: a house that omits axis titles is leaning
+ // on this line to name the measure.
+ const headline = input.chart_spec.title?.trim();
+ const deck = input.chart_spec.subtitle?.trim();
+ if (headline || deck) {
+ vgObj.title = {
+ text: headline ?? '',
+ ...(deck ? { subtitle: [deck] } : {}),
+ };
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // THEME (level 2 grounding → level 3 realization)
+ // ═══════════════════════════════════════════════════════════════════════
+ //
+ // Runs last, deliberately. `vlApplyLayoutToSpec` builds `config` wholesale
+ // from fit decisions; the theme is a style layer over a chart that already
+ // fits, so it must see the finished spec.
+ //
+ // Grounding runs whether or not a house was named — with the neutral house
+ // when it was not. Some design questions are Flint's own and want answering
+ // either way: whether this chart can carry its values, and at this density
+ // whether it should. A house enhances that answer (it may prefer values on,
+ // or tolerate a tighter chart); it does not own it. Only *realization* is
+ // gated, so an untheme'd chart gets its numbers without also getting a
+ // house's ink, type and furniture.
+ const markTypes = collectMarkTypes(vgObj);
+ // Some templates write their own text on the marks. Where they do, the
+ // label layer stands down (it will not print a second number beside the
+ // template's), so the toggle would be a control that changes nothing.
+ // Asked before realization, because realization is what adds the label
+ // layer — asked after, every labelled chart would look like this.
+ const templateDrawsOwnText = markTypes.includes('text');
+ const stackedChannel = (vgObj.spec?.encoding ?? vgObj.encoding ?? {});
+ const stacked = stackedChannel.y?.stack ?? stackedChannel.x?.stack;
+ const design = groundTheme(themeSpec ?? {}, {
+ chartType,
+ markChannel: chartTemplate.markCognitiveChannel,
+ markTypes,
+ namesOnMarks: (chartProperties as any)?.showSeriesInLabel === true,
+ channelSemantics,
+ resolvedTypes: declaration.resolvedTypes as Record | undefined,
+ axisFlags: declaration.axisFlags,
+ positional: collectPositional(vgObj),
+ layout: layoutResult,
+ table: values,
+ canvasSize,
+ stacked: stacked === 'normalize' ? 'normalize' : Boolean(stacked),
+ partToWhole: markTypes.includes('arc'),
+ titled: Boolean(vgObj.title),
+ hostSurface: (input.options as any)?.background,
+ valueLabels: resolveValueLabelChoice(chartProperties),
+ });
+
+ let themeDecisions: any;
+ if (themeSpec) {
+ const realizeReport = realizeThemeVegaLite(vgObj, design, values);
+ themeDecisions = {
+ ...design,
+ report: [...themePresets.report, ...chartDefaultsReport, ...design.report, ...realizeReport],
+ };
+ } else {
+ realizeValueLabelsVegaLite(vgObj, design, values);
+ }
+
// ═══════════════════════════════════════════════════════════════════════
// RESULT
// ═══════════════════════════════════════════════════════════════════════
const result: any = { ...vgObj, data: vgObj.data ?? { values } };
+ if (themeDecisions) {
+ result._theme = {
+ id: themeDecisions.themeId,
+ report: themeDecisions.report,
+ decisions: themeDecisions,
+ };
+ }
if (warnings.length > 0) {
result._warnings = warnings;
}
@@ -688,13 +834,40 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
data,
chartProperties,
};
+ const ownsLabels = templateOwnsValueLabels(chartTemplate);
+ const valueLabelChoice = resolveValueLabelChoice(chartProperties);
+ const explicitValueLabels = valueLabelChoice == null
+ ? undefined
+ : valueLabelChoice === 'on';
const layoutCoupledRecommendation: Record = {
independentYAxis: computedIndependentYAxis,
+ // Seed the labels toggle from what the house and the density actually
+ // decided, so an untouched control shows the theme's own habit and
+ // re-seeds when the reader switches theme. Where the template owns its
+ // labels, its own boolean is the honest answer.
+ showValueLabels: ownsLabels
+ ? explicitValueLabels ?? design?.dataLabels?.show
+ : design?.dataLabels?.show,
+ };
+ // Whether offering a labels control means anything is a question about the
+ // resolved layout — is there anything to key a number to, and is there room
+ // to print it — so only the grounded design can answer it. A chart too
+ // dense to read numbers on cannot be argued into it, and neither can one
+ // whose template already writes its own text. Templates that print labels
+ // *on request* are the exception: they answer to the toggle themselves.
+ const designCoupledApplicability: Record = {
+ showValueLabels: ownsLabels
+ || (design?.dataLabels?.possible === true && !templateDrawsOwnText),
+ // The older spelling stays an accepted *input* for compatibility, but a
+ // host should be shown one switch, not two that fight.
+ showTextLabels: false,
};
result._options = (chartTemplate.properties ?? []).map((def): ChartOption => {
const ev = def.check?.(evalCtx);
- const applicable = ev ? ev.applicable : true;
+ const applicable = def.key in designCoupledApplicability
+ ? designCoupledApplicability[def.key]
+ : ev ? ev.applicable : true;
const recommended = layoutCoupledRecommendation[def.key] ?? ev?.recommendedValue;
const value = chartProperties?.[def.key] ?? recommended ?? def.defaultValue;
// Strip the `check` rule — a ChartOption is the resolved, serializable
@@ -742,6 +915,34 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
return result;
}
+/**
+ * What the caller asked for on value labels, in one word.
+ *
+ * `showValueLabels` is the control; `showTextLabels` is the older boolean some
+ * templates and hosts still pass, and it keeps working — `true` means print,
+ * `false` means the caller never touched it (it was the key's default), so it
+ * reads as `auto` rather than as a demand for silence. Only the tri-state can
+ * say "off".
+ */
+/**
+ * Does this template print its own value labels, rather than leaving them to
+ * the theme's data-label layer?
+ */
+function templateOwnsValueLabels(template: ChartTemplateDef): boolean {
+ return template.ownsValueLabels === true;
+}
+
+function resolveValueLabelChoice(
+ chartProperties: Record | undefined,
+): 'on' | 'off' | undefined {
+ const choice = chartProperties?.showValueLabels;
+ if (typeof choice === 'boolean') return choice ? 'on' : 'off';
+ // `showTextLabels` is the older, template-owned spelling of the same wish.
+ // Only `true` is meaningful: it was the opt-in for charts that print their
+ // own numbers, so `false` means "never asked", not "asked for silence".
+ return chartProperties?.showTextLabels === true ? 'on' : undefined;
+}
+
/**
* Inspect a chart spec + dataset and report the configurable options Flint
* exposes for it, each annotated with whether it is *applicable* and the *value*
@@ -887,6 +1088,10 @@ function buildVLEncodings(
// Legend sizing for high-cardinality nominal color/group
if (encodingObj.type === "nominal" && (channel === 'color' || channel === 'group')) {
const actualDomain = [...new Set(data.map(r => r[fieldName]))];
+ // Threshold kept in sync with HIGH_CARDINALITY_LEGEND_MIN in
+ // vegalite/theme.ts: when a theme later folds the key to a short
+ // top-K + Others list, that pass recomputes this shrink against
+ // the folded count so short legends are not squeezed to 8px.
if (actualDomain.length >= 16) {
if (!encodingObj.legend) encodingObj.legend = {};
encodingObj.legend.symbolSize = 12;
diff --git a/packages/flint-js/src/vegalite/canvas-furniture.ts b/packages/flint-js/src/vegalite/canvas-furniture.ts
new file mode 100644
index 00000000..676aeaea
--- /dev/null
+++ b/packages/flint-js/src/vegalite/canvas-furniture.ts
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * @module flint-chart/vegalite/canvas-furniture
+ *
+ * Canvas-anchored furniture: branding marks that belong to the *graphic frame*,
+ * not the plot. The Economist red masthead tab is the archetype — it sits at
+ * the graphic's top-left, flush with the title, and has nothing to do with the
+ * data rectangle.
+ *
+ * Vega-Lite has no way to express this. Every mark it draws lives in the plot's
+ * coordinate space, and a concat child pins to the plot's *data* rectangle — so
+ * on a horizontal bar (a wide left-axis gutter) the tab drifts right, away from
+ * the title, instead of holding graphic-left. There is no `align`/`bounds`
+ * combination that lifts a child into the axis gutter; the wall is structural.
+ *
+ * The remedy is to draw the tab *after* Vega-Lite is done, straight onto the
+ * rendered SVG at absolute canvas coordinates. The grounding stage records
+ * where each piece goes (in `usermeta`, which Vega-Lite passes through to the
+ * compiled Vega spec untouched) and reserves a top band so nothing overlaps the
+ * title. The renderer then injects a plain `` at those coordinates. The
+ * result is a real element inside the exported `');
+ if (idx === -1) return svg;
+ return svg.slice(0, idx) + canvasFurnitureMarkup(items) + svg.slice(idx);
+}
diff --git a/packages/flint-js/src/vegalite/index.ts b/packages/flint-js/src/vegalite/index.ts
index c6a5d62e..e837543f 100644
--- a/packages/flint-js/src/vegalite/index.ts
+++ b/packages/flint-js/src/vegalite/index.ts
@@ -16,6 +16,15 @@ export { assembleVegaLite, getChartOptions, getChartPivot, getChartTransform } f
// VL spec instantiation (Phase 2)
export { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec';
+// Canvas-anchored furniture (branding marks drawn onto the rendered SVG)
+export {
+ type CanvasFurnitureItem,
+ CANVAS_FURNITURE_KEY,
+ readCanvasFurniture,
+ canvasFurnitureMarkup,
+ injectCanvasFurnitureSVG,
+} from './canvas-furniture';
+
// VL template registry
export {
vlTemplateDefs,
diff --git a/packages/flint-js/src/vegalite/instantiate-spec.ts b/packages/flint-js/src/vegalite/instantiate-spec.ts
index 1b381f6f..a755b87d 100644
--- a/packages/flint-js/src/vegalite/instantiate-spec.ts
+++ b/packages/flint-js/src/vegalite/instantiate-spec.ts
@@ -173,43 +173,47 @@ export function vlApplyLayoutToSpec(
const bandedCount = axis === 'x' ? layout.xContinuousAsDiscrete : layout.yContinuousAsDiscrete;
if (bandedCount <= 1) continue;
- const enc = vgObj.encoding?.[axis] || vgObj.spec?.encoding?.[axis];
- if (!enc) continue;
-
- // Skip binned encodings — VL handles bin domain automatically
- if (enc.bin) continue;
-
- const isTemporal = enc.type === 'temporal';
- const isContinuous = enc.type === 'quantitative' || isTemporal;
- if (!isContinuous) continue;
- if (enc.scale?.domain) continue;
-
- const numericVals = context.table
- .map((r: any) => {
- const raw = r[enc.field];
- if (raw == null) return NaN;
- if (isTemporal) return +new Date(raw);
- return +raw;
- })
- .filter((v: number) => !isNaN(v));
- if (numericVals.length <= 1) continue;
-
- const minVal = Math.min(...numericVals);
- const maxVal = Math.max(...numericVals);
- const dataRange = maxVal - minVal;
- if (dataRange === 0) continue;
-
- const pad = dataRange / (bandedCount - 1) / 2;
- if (!enc.scale) enc.scale = {};
- enc.scale.nice = false;
-
- if (isTemporal) {
- enc.scale.domain = [
- new Date(minVal - pad).toISOString(),
- new Date(maxVal + pad).toISOString(),
- ];
- } else {
- enc.scale.domain = [minVal - pad, maxVal + pad];
+ // Labelled heatmaps move X/Y onto rect and text layers. Looking only at
+ // the top-level encoding skips both, so temporal edge cells lose their
+ // half-step domain and are clipped against the axis. Apply the same
+ // domain to every matching layer target; shared scales then resolve
+ // consistently and neither layer introduces a competing boundary.
+ for (const enc of collectEncodingTargets(axis)) {
+ // Skip binned encodings — VL handles bin domain automatically
+ if (enc.bin) continue;
+
+ const isTemporal = enc.type === 'temporal';
+ const isContinuous = enc.type === 'quantitative' || isTemporal;
+ if (!isContinuous) continue;
+ if (enc.scale?.domain) continue;
+
+ const numericVals = context.table
+ .map((r: any) => {
+ const raw = r[enc.field];
+ if (raw == null) return NaN;
+ if (isTemporal) return +new Date(raw);
+ return +raw;
+ })
+ .filter((v: number) => !isNaN(v));
+ if (numericVals.length <= 1) continue;
+
+ const minVal = Math.min(...numericVals);
+ const maxVal = Math.max(...numericVals);
+ const dataRange = maxVal - minVal;
+ if (dataRange === 0) continue;
+
+ const pad = dataRange / (bandedCount - 1) / 2;
+ if (!enc.scale) enc.scale = {};
+ enc.scale.nice = false;
+
+ if (isTemporal) {
+ enc.scale.domain = [
+ new Date(minVal - pad).toISOString(),
+ new Date(maxVal + pad).toISOString(),
+ ];
+ } else {
+ enc.scale.domain = [minVal - pad, maxVal + pad];
+ }
}
}
@@ -228,6 +232,14 @@ export function vlApplyLayoutToSpec(
labelFontSize: layout.yLabel.fontSize,
titleFontSize: layout.titleFontSize,
};
+ // Vega drops a tick label only once its box *overlaps* its neighbour's, so
+ // two numbers whose boxes merely abut both survive and are read as one:
+ // `20,000` beside `30,000` prints `20,00030,000`. Numbers need a
+ // character's worth of air between them before they read as two. Bands are
+ // exempt — their labels are spaced by the scale, and thinning them drops a
+ // category rather than a tick.
+ if (!xIsDiscrete) axisXConfig.labelSeparation = Math.round(layout.xLabel.fontSize * 0.6);
+ if (!yIsDiscrete) axisYConfig.labelSeparation = Math.round(layout.yLabel.fontSize * 0.6);
vgObj.config = {
view: {
diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts
index 7bef3b80..38721a11 100644
--- a/packages/flint-js/src/vegalite/templates/area.ts
+++ b/packages/flint-js/src/vegalite/templates/area.ts
@@ -3,7 +3,7 @@
import { ChartTemplateDef, ChartPropertyDef } from '../../core/types';
import { makeCartesianPivot } from '../../core/pivot';
-import { defaultBuildEncodings, setMarkProp } from './utils';
+import { defaultBuildEncodings, setMarkProp, alignStackOrderToColorOrder } from './utils';
const interpolateConfigProperty: ChartPropertyDef = {
key: "interpolate", label: "Curve", type: "discrete", options: [
@@ -147,6 +147,7 @@ export const areaChartDef: ChartTemplateDef = {
} else if (config?.stackMode !== 'layered') {
interpolateSparseStack(spec, ctx);
}
+ alignStackOrderToColorOrder(spec, ctx);
},
properties: [
interpolateConfigProperty,
@@ -196,6 +197,7 @@ export const streamgraphDef: ChartTemplateDef = {
// A streamgraph is always centre-stacked → interpolate sparse gaps so the
// stack stays continuous (see interpolateSparseStack).
interpolateSparseStack(spec, ctx);
+ alignStackOrderToColorOrder(spec, ctx);
},
properties: [interpolateConfigProperty] as ChartPropertyDef[],
};
diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts
index 35216767..c89a64aa 100644
--- a/packages/flint-js/src/vegalite/templates/bar.ts
+++ b/packages/flint-js/src/vegalite/templates/bar.ts
@@ -11,9 +11,17 @@ import {
} from '../../core/axis-detection';
import {
defaultBuildEncodings, setMarkProp, adjustBarMarks, adjustRectTiling,
- resolveAsDiscrete,
+ resolveAsDiscrete, alignStackOrderToColorOrder,
} from './utils';
+/**
+ * Fraction of a lane's pitch a locally-dodged bar fills, leaving a small gap
+ * between the bars inside one band. A house that states its own
+ * `marks.bandFraction` re-cuts against this baseline (see theme.ts `bandWalk`),
+ * so the two must agree on the number.
+ */
+export const LOCAL_DODGE_LANE_FILL = 0.85;
+
const HEATMAP_SCHEME_COLORS: Record = {
viridis: ['#440154', '#fde725'],
inferno: ['#000004', '#fcffa4'],
@@ -293,11 +301,12 @@ export const groupedBarChartDef: ChartTemplateDef = {
{ joinaggregate: [{ op: 'distinct', field: groupField, as: '__localCount' }], groupby: [axisField] },
{ calculate: `((datum.__laneIdx - 1) - (datum.__localCount - 1) / 2) / ${maxPB}`, as: '__off' },
];
- // Constant bar width ≈ 85% of a lane. VL's band reserves ~20%
- // padding, so the usable per-lane pitch is (band·0.8 / maxPerBand).
+ // Constant bar width ≈ LOCAL_DODGE_LANE_FILL of a lane. VL's
+ // band reserves ~20% padding, so the usable per-lane pitch is
+ // (band·0.8 / maxPerBand).
const band = offsetCh === 'xOffset' ? ctx.layout?.xStep : ctx.layout?.yStep;
if (band) {
- spec.mark = setMarkProp(spec.mark, 'size', Math.max(2, Math.round((band * 0.8 / maxPB) * 0.85)));
+ spec.mark = setMarkProp(spec.mark, 'size', Math.max(2, Math.round((band * 0.8 / maxPB) * LOCAL_DODGE_LANE_FILL)));
}
}
}
@@ -366,6 +375,7 @@ export const stackedBarChartDef: ChartTemplateDef = {
}
}
}
+ alignStackOrderToColorOrder(spec, ctx);
adjustBarMarks(spec, ctx);
},
properties: [
@@ -405,6 +415,12 @@ export const histogramDef: ChartTemplateDef = {
},
channels: ["x", "color", "column", "row"],
markCognitiveChannel: 'length',
+ // A binned x is an index axis, not a measure: the reader keys counts off
+ // its intervals, and its identity comes from banding even though the field
+ // is quantitative. Declaring it banded keeps the count off it and stops a
+ // house that seats its *measure* axis opposite (economist's right/top) from
+ // flipping the bins to the top of the plot.
+ declareLayoutMode: () => ({ axisFlags: { x: { banded: true } } }),
instantiate: (spec, ctx) => {
defaultBuildEncodings(spec, ctx.resolvedEncodings);
// `binCount` is the maxbins cap; 0 (auto) leaves the template's `bin: true`
@@ -437,9 +453,14 @@ export const heatmapDef: ChartTemplateDef = {
template: { mark: "rect", encoding: {} },
channels: ["x", "y", "color", "column", "row"],
markCognitiveChannel: 'color',
- declareLayoutMode: (_cs, _table, chartProperties) => {
+ ownsValueLabels: true,
+ declareLayoutMode: (_channelSemantics, _table, chartProperties) => {
const showTextLabels = !!chartProperties?.showTextLabels;
return {
+ // Heatmap positions are cells, regardless of whether their labels
+ // are categories, numbers, or dates. Temporal axes keep a temporal
+ // scale for tick semantics while the dynamic layout budgets one
+ // discrete slot per observed value (continuous-as-discrete).
axisFlags: { x: { banded: true }, y: { banded: true } },
// Labels need slightly larger cells so the value text isn't crushed,
// but we keep this close to the unlabeled defaults (minStep 6 /
@@ -458,9 +479,17 @@ export const heatmapDef: ChartTemplateDef = {
const colorField = spec.encoding?.color?.field;
const colorVals = colorField
? ctx.table
- .map((r: any) => Number(r[colorField]))
+ .map((r: any) => r[colorField])
+ .filter((v: any) => v != null && v !== '')
+ .map((v: any) => Number(v))
.filter((v: number) => Number.isFinite(v))
: [];
+ const hasMissingValues = colorField
+ ? ctx.table.some((r: any) => {
+ const value = r[colorField];
+ return value == null || value === '' || !Number.isFinite(Number(value));
+ })
+ : false;
const observedMin = colorVals.length > 0 ? Math.min(...colorVals) : 0;
const observedMax = colorVals.length > 0 ? Math.max(...colorVals) : 1;
const existingScheme = spec.encoding?.color?.scale?.scheme;
@@ -478,8 +507,14 @@ export const heatmapDef: ChartTemplateDef = {
&& !semanticIsDiverging
&& !isDivergingHeatmapScheme(existingScheme)
&& colorEncodingType !== 'nominal';
+ // A diverging heatmap has a polarity, and the polarity is a reading of
+ // the field: warm at the top for an intensity, red at the bottom for a
+ // loss (see the diverging note in semantic-types). That call has
+ // already been made upstream, so take the scheme it named rather than
+ // pinning one here — a hard-coded default lands cold-red on every
+ // temperature grid we draw.
const schemeName = userScheme
- || (semanticIsDiverging ? (existingScheme || 'redblue') : undefined)
+ || (semanticIsDiverging ? (existingScheme || semanticScheme?.scheme || 'redblue') : undefined)
|| (shouldUseHeatmapDefault ? DEFAULT_HEATMAP_SCHEME : existingScheme);
const isDiverging = isDivergingHeatmapScheme(schemeName);
const intrinsicDomain = getSafeHeatmapIntrinsicDomain(ctx, colorField);
@@ -492,12 +527,20 @@ export const heatmapDef: ChartTemplateDef = {
if (schemeName) {
spec.encoding.color.scale.scheme = schemeName;
}
- if (isDiverging && effectiveMin < 0 && effectiveMax > 0) {
- const sym = Math.max(Math.abs(effectiveMin), Math.abs(effectiveMax));
- effectiveMin = -sym;
- effectiveMax = sym;
- spec.encoding.color.scale.domain = [-sym, sym];
- spec.encoding.color.scale.domainMid = 0;
+ // A diverging grid has to be symmetric about its pivot, or one arm
+ // of the ramp reaches further than the other and equal distances
+ // from the pivot read as unequal. The pivot is not always zero —
+ // an author can say what the reader is comparing against — so
+ // centre on whatever was resolved rather than on the origin.
+ const pivot = spec.encoding.color.scale.domainMid
+ ?? semanticScheme?.domainMid
+ ?? 0;
+ if (isDiverging && effectiveMin < pivot && effectiveMax > pivot) {
+ const sym = Math.max(pivot - effectiveMin, effectiveMax - pivot);
+ effectiveMin = pivot - sym;
+ effectiveMax = pivot + sym;
+ spec.encoding.color.scale.domain = [effectiveMin, effectiveMax];
+ spec.encoding.color.scale.domainMid = pivot;
} else if (intrinsicDomain) {
// Sequential color with a known intrinsic domain (e.g. a
// Percentage field with [0, 100]). Don't force the full
@@ -516,10 +559,13 @@ export const heatmapDef: ChartTemplateDef = {
adjustBarMarks(spec, ctx);
adjustRectTiling(spec, ctx);
- if (showTextLabels && spec.encoding?.color?.field) {
+ if ((showTextLabels || hasMissingValues) && spec.encoding?.color?.field) {
const baseEncoding = spec.encoding || {};
const xEncoding = baseEncoding.x;
const yEncoding = baseEncoding.y;
+ const colorValue = `datum[${JSON.stringify(colorField)}]`;
+ const validValue = `isValid(${colorValue}) && ${colorValue} !== ''`;
+ const missingValue = `!(${validValue})`;
const span = effectiveMax - effectiveMin;
const cellMinDim = Math.min(ctx.layout.xStep || 50, ctx.layout.yStep || 50);
@@ -537,53 +583,89 @@ export const heatmapDef: ChartTemplateDef = {
: effectiveMin + span * 0.6)
: undefined;
- spec.layer = [
- {
+ if (hasMissingValues) {
+ // Keep no-data styling on the original rect encoding. A
+ // separate missing-value layer owns its own X/Y definitions;
+ // even with shared scales, that layer then participates in
+ // axis inference and can disturb a transposed temporal axis.
+ spec.encoding.color = {
+ ...spec.encoding.color,
+ condition: { test: missingValue, value: '#8c8c8c' },
+ };
+ spec.encoding.opacity = {
+ condition: { test: missingValue, value: 0.32 },
+ value: 1,
+ };
+ }
+
+ if (showTextLabels) {
+ const defaultTextColor = isDiverging
+ ? 'black'
+ : (highIsLight ? 'white' : 'black');
+ const textColorConditions: any[] = [
+ ...(hasMissingValues
+ ? [{ test: missingValue, value: '#8c8c8c' }]
+ : []),
+ ...(strongThreshold == null
+ ? []
+ : [{
+ test: isDiverging
+ ? `${colorValue} > ${strongThreshold} || ${colorValue} < ${-strongThreshold}`
+ : `${colorValue} >= ${strongThreshold}`,
+ value: isDiverging
+ ? 'white'
+ : (highIsLight ? 'black' : 'white'),
+ }]),
+ ];
+ const layers: any[] = [{
mark: spec.mark,
encoding: {
...(xEncoding ? { x: xEncoding } : {}),
...(yEncoding ? { y: yEncoding } : {}),
- ...(baseEncoding.color ? { color: baseEncoding.color } : {}),
+ ...(baseEncoding.color ? { color: spec.encoding.color } : {}),
+ ...(spec.encoding.opacity ? { opacity: spec.encoding.opacity } : {}),
},
- },
- {
+ }, {
mark: {
type: 'text',
align: 'center',
baseline: 'middle',
fontSize: labelFontSize,
+ clip: true,
},
encoding: {
...(xEncoding ? { x: xEncoding } : {}),
...(yEncoding ? { y: yEncoding } : {}),
text: {
+ ...(hasMissingValues
+ ? { condition: { test: missingValue, value: '—' } }
+ : {}),
field: colorField,
type: 'quantitative',
format: labelFormat,
},
- color: strongThreshold == null
- ? { value: 'black' }
- : {
- condition: {
- test: isDiverging
- ? `datum.${colorField} > ${strongThreshold} || datum.${colorField} < ${-strongThreshold}`
- : `datum.${colorField} >= ${strongThreshold}`,
- value: isDiverging
- ? 'white'
- : (highIsLight ? 'black' : 'white'),
- },
- value: isDiverging
- ? 'black'
- : (highIsLight ? 'white' : 'black'),
- },
+ color: textColorConditions.length > 0
+ ? { condition: textColorConditions, value: defaultTextColor }
+ : { value: defaultTextColor },
},
- },
- ];
- delete spec.mark;
+ }];
+
+ spec.layer = layers;
+ delete spec.mark;
+
+ // Facets remain shared by the layered unit, but X/Y/color now
+ // live on the individual layers.
+ const sharedEncoding = {
+ ...(baseEncoding.column ? { column: baseEncoding.column } : {}),
+ ...(baseEncoding.row ? { row: baseEncoding.row } : {}),
+ };
+ if (Object.keys(sharedEncoding).length > 0) spec.encoding = sharedEncoding;
+ else delete spec.encoding;
+ }
}
},
properties: [
- { key: 'showTextLabels', label: 'Labels', type: 'binary', defaultValue: false },
+ { key: 'showValueLabels', label: 'Values', 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/bump.ts b/packages/flint-js/src/vegalite/templates/bump.ts
index f84cd9ae..351dfe09 100644
--- a/packages/flint-js/src/vegalite/templates/bump.ts
+++ b/packages/flint-js/src/vegalite/templates/bump.ts
@@ -3,6 +3,7 @@
import { ChartTemplateDef } from '../../core/types';
import { defaultBuildEncodings } from './utils';
+import { interpolateConfigProperty, applyInterpolate } from './line';
/** Semantic types that indicate a rank-like field */
const RANK_SEMANTIC_TYPES = new Set(['Rank', 'Score', 'Level']);
@@ -13,17 +14,25 @@ const isDiscrete = (type: string | undefined) =>
export const bumpChartDef: ChartTemplateDef = {
chart: "Bump Chart",
template: {
- mark: { type: "line", point: true, interpolate: "monotone", strokeWidth: 2 },
+ mark: { type: "line", point: true, interpolate: "linear", strokeWidth: 2 },
encoding: {},
},
channels: ["x", "y", "color", "detail", "column", "row"],
markCognitiveChannel: 'position',
+ properties: [interpolateConfigProperty],
declareLayoutMode: () => ({
paramOverrides: { continuousMarkCrossSection: { x: 80, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.4 },
}),
instantiate: (spec, ctx) => {
defaultBuildEncodings(spec, ctx.resolvedEncodings);
+ // Straight segments between one standing and the next. A curve would
+ // draw a rank the reader can point at halfway between two Games, and
+ // there was no such rank — nothing was measured between them. A caller
+ // or a house that wants the softer read says so through the curve
+ // option.
+ spec.mark = applyInterpolate(spec.mark, ctx.chartProperties);
+
const xEnc = spec.encoding?.x;
const yEnc = spec.encoding?.y;
if (!xEnc || !yEnc) return;
@@ -62,5 +71,79 @@ export const bumpChartDef: ChartTemplateDef = {
type: yEnc.type || "quantitative",
};
}
+
+ applyRankScale(spec.encoding[rankAxis], ctx, rankAxis);
+ padSequenceEnds(spec.encoding[rankAxis === 'y' ? 'x' : 'y']);
},
};
+
+/**
+ * A rank runs from first to last, and there is no zeroth place.
+ *
+ * The axis a bump chart is read against is a standing, whatever the field was
+ * tagged as — the template picked it out for exactly that reason. So it is
+ * fitted to the standings that exist: an author's declared bounds if there are
+ * any, otherwise the ranks in the data. A zero baseline here is not a
+ * conservative choice, it is a tick for a position nobody can finish in, and
+ * it costs the chart a fifth of its height.
+ *
+ * Both ends then get a little air. First place drawn on the frame reads as
+ * clipped rather than as first, and the label riding on it has nowhere to sit.
+ */
+function applyRankScale(enc: any, ctx: any, rankAxis: 'x' | 'y'): void {
+ if (!enc?.field || enc.type !== 'quantitative') return;
+
+ const declared = ctx.channelSemantics?.[rankAxis]?.semanticAnnotation?.intrinsicDomain;
+ let domain: [number, number] | undefined = Array.isArray(declared) && declared.length === 2
+ ? [declared[0], declared[1]]
+ : undefined;
+
+ if (!domain) {
+ let min = Infinity;
+ let max = -Infinity;
+ for (const row of ctx.table ?? []) {
+ const v = Number(row?.[enc.field]);
+ if (!Number.isFinite(v)) continue;
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return;
+ domain = [min, max];
+ }
+
+ enc.scale = { ...enc.scale, domain, zero: false, nice: false, padding: RANK_END_PAD };
+
+ // Every place gets its own tick while there are few enough of them to
+ // name. A generic tick count on a rank axis lands on 2, 4, 6 and leaves
+ // first place — the one line the reader came for — with no label at all.
+ const [lo, hi] = domain;
+ if (Number.isInteger(lo) && Number.isInteger(hi) && hi - lo <= MAX_NAMED_RANKS) {
+ const values: number[] = [];
+ for (let v = lo; v <= hi; v++) values.push(v);
+ // The count travels with the values: a house that asks for four ticks
+ // on its value axes means four on a measured axis, but here the ticks
+ // are the places themselves, and Vega thins a named list down to the
+ // house count unless it is told how many names there are.
+ enc.axis = { ...enc.axis, values, tickCount: values.length };
+ }
+}
+
+/** Ranks we will label one by one before falling back to the axis's own count. */
+const MAX_NAMED_RANKS = 11;
+
+/** Room at the ends of the scale, in pixels. */
+const RANK_END_PAD = 14;
+const SEQUENCE_END_PAD = 10;
+
+/**
+ * Air at the start and the end of the sequence axis.
+ *
+ * The first reading sits on the value axis and the last sits on the frame,
+ * which puts a point and its name in the same pixels as the axis labels. The
+ * padding is what the reader would leave if they were drawing it by hand.
+ */
+function padSequenceEnds(enc: any): void {
+ if (!enc?.field || enc.type === 'nominal' || enc.type === 'ordinal') return;
+ if (enc.scale?.padding != null) return;
+ enc.scale = { ...enc.scale, padding: SEQUENCE_END_PAD };
+}
diff --git a/packages/flint-js/src/vegalite/templates/candlestick.ts b/packages/flint-js/src/vegalite/templates/candlestick.ts
index 10493a5e..0fdb598d 100644
--- a/packages/flint-js/src/vegalite/templates/candlestick.ts
+++ b/packages/flint-js/src/vegalite/templates/candlestick.ts
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
import { ChartTemplateDef } from '../../core/types';
+import { adjustBarMarks } from './utils';
export const candlestickChartDef: ChartTemplateDef = {
chart: "Candlestick Chart",
@@ -44,28 +45,55 @@ export const candlestickChartDef: ChartTemplateDef = {
if (close) spec.layer[1].encoding.y2 = { field: close.field };
if (open?.field && close?.field) {
+ // `<=`, not `<`: a session that closes exactly where it opened has
+ // not fallen, and colouring it as a decline is a false statement.
spec.encoding.color = {
condition: {
- test: `datum['${open.field}'] < datum['${close.field}']`,
+ test: `datum['${open.field}'] <= datum['${close.field}']`,
value: "#06982d",
},
value: "#ae1325",
};
}
- // Compute bar width from x-axis cardinality
- const table = ctx.table;
- const plotWidth = ctx.canvasSize?.width || 400;
- const xField = spec.encoding?.x?.field;
- let barSize: number;
-
- if (xField && table?.length > 0) {
- const cardinality = new Set(table.map((r: any) => r[xField])).size;
- barSize = Math.max(2, Math.min(20, Math.round(plotWidth * 0.6 / cardinality)));
+ // Body width.
+ //
+ // On a banded *continuous* x — the usual case, dates — the slot width
+ // is set by the smallest gap between observations, not by the row
+ // count: nine trading days spanning eleven calendar days occupy eleven
+ // slots, two of which are the weekend. Sizing on cardinality makes
+ // every body wider than its own slot and adjacent candles fuse into a
+ // single polygon. adjustBarMarks() already performs that min-gap
+ // analysis for bar marks, so use it rather than keep a second, wrong
+ // copy of the arithmetic here.
+ //
+ // It returns the largest *non-overlapping* size, and bodies that
+ // merely touch still read as one shape when consecutive sessions move
+ // the same way. A candlestick needs a visible gutter, so take a
+ // fraction of the fitted width.
+ const BODY_FILL = 0.8;
+ if ((ctx.layout?.xContinuousAsDiscrete ?? 0) > 0) {
+ adjustBarMarks(spec, ctx);
+ const fitted = (spec.layer[1].mark as { size?: number })?.size ?? 14;
+ spec.layer[1].mark = { ...spec.layer[1].mark, size: Math.max(2, Math.floor(fitted * BODY_FILL)) };
} else {
- barSize = 14;
+ const step = ctx.layout?.xStep ?? 20;
+ spec.layer[1].mark = { ...spec.layer[1].mark, size: Math.max(2, Math.round(step * BODY_FILL)) };
}
- spec.layer[1].mark = { ...spec.layer[1].mark, size: barSize };
+ // Doji sessions.
+ //
+ // When open === close the open→close bar has zero height and vanishes,
+ // so a flat session renders as a bare wick with no candle on it. Draw
+ // it as a horizontal tick at the shared price, which is the convention
+ // and is exactly what the bar degenerates to.
+ if (open?.field && close?.field) {
+ const bodySize = (spec.layer[1].mark as { size?: number })?.size ?? 14;
+ spec.layer.push({
+ transform: [{ filter: `datum['${open.field}'] === datum['${close.field}']` }],
+ mark: { type: "tick", size: bodySize, thickness: 2 },
+ encoding: { y: { field: close.field } },
+ });
+ }
},
};
diff --git a/packages/flint-js/src/vegalite/templates/connected-scatter.ts b/packages/flint-js/src/vegalite/templates/connected-scatter.ts
index 151a7ff4..cf17b03d 100644
--- a/packages/flint-js/src/vegalite/templates/connected-scatter.ts
+++ b/packages/flint-js/src/vegalite/templates/connected-scatter.ts
@@ -59,7 +59,7 @@ function resolveOrderType(
export const connectedScatterDef: ChartTemplateDef = {
chart: "Connected Scatter Plot",
template: {
- mark: { type: "line", point: true, interpolate: "linear", strokeWidth: 2 },
+ mark: { type: "line", point: true, interpolate: "linear" },
encoding: {},
},
channels: ["x", "y", "order", "color", "detail", "column", "row"],
diff --git a/packages/flint-js/src/vegalite/templates/index.ts b/packages/flint-js/src/vegalite/templates/index.ts
index 2fe6ace4..d364e567 100644
--- a/packages/flint-js/src/vegalite/templates/index.ts
+++ b/packages/flint-js/src/vegalite/templates/index.ts
@@ -210,6 +210,42 @@ const AXIS_DTYPE_PROPERTIES: ChartPropertyDef[] = [
},
];
+/**
+ * The reader's answer to "print the numbers on the marks?".
+ *
+ * A plain switch, seeded from what the house and the density already decided:
+ * the compiler supplies the recommended default at assembly time (same shape as
+ * `independentYAxis`), so an untouched control shows the theme's own habit and
+ * flipping it is an explicit decision about *this* chart.
+ *
+ * Switching it on is still not a licence to overprint — it inherits the same
+ * hard density ceiling the houses obey. And where that ceiling is already
+ * breached the control is reported inapplicable rather than offered inert: the
+ * compiler answers this from the grounded `dataLabels.possible`, so what the
+ * host shows can never drift from what the compiler would do.
+ */
+const VALUE_LABEL_PROPERTIES: ChartPropertyDef[] = [
+ {
+ key: 'showValueLabels', label: 'Values', type: 'binary',
+ defaultValue: false,
+ // Both applicability and the recommended default are measured, not
+ // guessed: they need the resolved layout (band width) and the house's
+ // policy, neither of which exists this early.
+ check: () => ({ applicable: false }),
+ },
+];
+
+/**
+ * Charts that can print one number per mark: a banded axis to key values to,
+ * or wedges of a whole. Stacked bars are absent on purpose — a number at a
+ * segment edge reads as the running total, and the compiler refuses to print
+ * them for the same reason.
+ */
+const VALUE_LABEL_CHARTS = new Set([
+ 'Bar Chart', 'Grouped Bar Chart', 'Stacked Bar Chart', 'Lollipop Chart', 'Pyramid Chart',
+ 'Pie Chart', 'Donut Chart', 'Rose Chart', 'Heatmap', 'Waterfall Chart',
+]);
+
/**
* Attach the cross-cutting properties (faceting, log scale, axis dtype) a
* template qualifies for, based on its channels and mark-cognitive role. Keeps
@@ -227,6 +263,7 @@ function withInjectedProperties(def: ChartTemplateDef): ChartTemplateDef {
...(isPosition ? LOG_SCALE_PROPERTIES : []),
...(isPosition ? ZERO_BASELINE_PROPERTIES : []),
...(wantsAxisDtype ? AXIS_DTYPE_PROPERTIES : []),
+ ...(VALUE_LABEL_CHARTS.has(def.chart) ? VALUE_LABEL_PROPERTIES : []),
];
if (extra.length === 0) return def;
const ownKeys = new Set((def.properties ?? []).map(p => p.key));
diff --git a/packages/flint-js/src/vegalite/templates/kpi-card.ts b/packages/flint-js/src/vegalite/templates/kpi-card.ts
index be2ffb2a..e68df82e 100644
--- a/packages/flint-js/src/vegalite/templates/kpi-card.ts
+++ b/packages/flint-js/src/vegalite/templates/kpi-card.ts
@@ -372,6 +372,10 @@ export const kpiCardDef: ChartTemplateDef = {
: PROGRESS_ON_TRACK;
layers.push({
+ // Only the exceeded state paints this line a status hue;
+ // otherwise it is ordinary caption grey and re-tones with
+ // the rest of the card's text.
+ ...(isExceeded ? { __themeRole: 'positive' } : {}),
data: { values: [{}] },
mark: {
type: 'text',
@@ -410,6 +414,11 @@ export const kpiCardDef: ChartTemplateDef = {
// that the goal was exceeded.
const fillEnd = barLeft + Math.min(1, pct) * barWidth;
layers.push({
+ // The bar is the only part of the card that carries a
+ // measurement, so it takes the house's ink: its accent
+ // where the reading is simply in progress, and the
+ // house's status inks where the reading has a verdict.
+ __themeRole: isExceeded ? 'positive' : isBehind ? 'negative' : 'accent',
data: { values: [{}] },
mark: {
type: 'rect',
diff --git a/packages/flint-js/src/vegalite/templates/line.ts b/packages/flint-js/src/vegalite/templates/line.ts
index 484d1a5a..c61a34ca 100644
--- a/packages/flint-js/src/vegalite/templates/line.ts
+++ b/packages/flint-js/src/vegalite/templates/line.ts
@@ -28,11 +28,38 @@ export function applyInterpolate(mark: any, config?: Record): any {
return setMarkProp(mark, 'interpolate', config.interpolate);
}
-function applyShowPoints(mark: any, config?: Record): any {
- if (!config?.showPoints) return mark;
+function applyShowPoints(mark: any, ctx: InstantiateContext): any {
+ if (!ctx.chartProperties?.showPoints) return mark;
+ // Points on a line name where a value was measured. Past the density at
+ // which they touch, they stop being points and become a texture that buries
+ // the line under them — a house habit meeting a fact about fit. Yield the
+ // overlay when the readings pack tighter than a small dot can sit apart.
+ if (pointsTooDense(ctx)) return mark;
return setMarkProp(mark, 'point', true);
}
+/**
+ * True when a line carries more readings per series than can be drawn as
+ * separate dots: the per-series point spacing falls below the width a small
+ * dot needs to read as one. Measured at the base width (the honest floor before
+ * any stretch), against the densest reasonable series estimate (rows ÷ series).
+ */
+function pointsTooDense(ctx: InstantiateContext): boolean {
+ const rows = ctx.table?.length ?? 0;
+ if (rows === 0) return false;
+ const seriesField = ctx.resolvedEncodings?.color?.field ?? ctx.resolvedEncodings?.detail?.field;
+ let series = 1;
+ if (seriesField) {
+ const seen = new Set();
+ for (const r of ctx.table) seen.add(r[seriesField]);
+ series = Math.max(1, seen.size);
+ }
+ const pointsPerSeries = rows / series;
+ const width = ctx.canvasSize?.width ?? 300;
+ const spacing = width / Math.max(1, pointsPerSeries);
+ return spacing < 8;
+}
+
function isContinuousColor(ctx: InstantiateContext): boolean {
const color = ctx.resolvedEncodings.color;
if (!color?.field) return false;
@@ -101,7 +128,7 @@ export const lineChartDef: ChartTemplateDef = {
}
defaultBuildEncodings(spec, ctx.resolvedEncodings);
spec.mark = applyInterpolate(spec.mark, ctx.chartProperties);
- spec.mark = applyShowPoints(spec.mark, ctx.chartProperties);
+ spec.mark = applyShowPoints(spec.mark, ctx);
},
properties: [interpolateConfigProperty, showPointsProperty],
// No `transpose`: a line pins its domain to `x` (never a vertical line, for any
diff --git a/packages/flint-js/src/vegalite/templates/pie.ts b/packages/flint-js/src/vegalite/templates/pie.ts
index d8eb4c3b..d611efe7 100644
--- a/packages/flint-js/src/vegalite/templates/pie.ts
+++ b/packages/flint-js/src/vegalite/templates/pie.ts
@@ -108,7 +108,26 @@ export const pieChartDef: ChartTemplateDef = {
] as ChartPropertyDef[],
};
+/** The hole a Donut Chart gets when the caller sets no `innerRadius`. */
+const DONUT_DEFAULT_INNER_RADIUS = 50;
+
export const donutChartDef: ChartTemplateDef = {
...pieChartDef,
chart: "Donut Chart",
+ // A donut is a pie with a hole. Property defaults are not merged into
+ // `chartProperties` at assemble time (only discrete options are coerced),
+ // so a Donut Chart authored without an explicit `innerRadius` would inherit
+ // the pie's hole-less 0 and render as a full pie. Carry a non-zero default
+ // and apply it here before delegating to the pie's instantiate.
+ properties: (pieChartDef.properties ?? []).map((p) =>
+ p.key === 'innerRadius' ? { ...p, defaultValue: DONUT_DEFAULT_INNER_RADIUS } : p,
+ ) as ChartPropertyDef[],
+ instantiate: (spec, ctx) => {
+ const innerRadius = ctx.chartProperties?.innerRadius;
+ const withHole =
+ innerRadius == null
+ ? { ...ctx, chartProperties: { ...(ctx.chartProperties ?? {}), innerRadius: DONUT_DEFAULT_INNER_RADIUS } }
+ : ctx;
+ pieChartDef.instantiate(spec, withHole);
+ },
};
diff --git a/packages/flint-js/src/vegalite/templates/radar.ts b/packages/flint-js/src/vegalite/templates/radar.ts
index ef491dc1..31682cbf 100644
--- a/packages/flint-js/src/vegalite/templates/radar.ts
+++ b/packages/flint-js/src/vegalite/templates/radar.ts
@@ -230,9 +230,14 @@ function buildRadarLayers(
},
};
if (groups.length > 1 && groupField) {
+ // Stroke, fill and point colour all carry `__group`; Vega-Lite merges
+ // them into a single legend for the field. Leaving the legend off any
+ // one of them (a `legend: null`) removes the key from *all* of them,
+ // because the layers share the colour scale — so the series would be
+ // unidentifiable. Let all three share the one merged legend.
lineLayer.encoding.stroke = { field: "__group", type: "nominal", title: groupField };
if (filled) {
- lineLayer.encoding.fill = { field: "__group", type: "nominal", title: groupField, legend: null };
+ lineLayer.encoding.fill = { field: "__group", type: "nominal", title: groupField };
}
} else if (filled) {
lineLayer.mark.fill = "#4c78a8";
@@ -254,7 +259,7 @@ function buildRadarLayers(
},
};
if (groups.length > 1 && groupField) {
- pointLayer.encoding.color = { field: "__group", type: "nominal", title: groupField, legend: null };
+ pointLayer.encoding.color = { field: "__group", type: "nominal", title: groupField };
}
layers.push(pointLayer);
diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts
index 0f8d3fe1..29a21f43 100644
--- a/packages/flint-js/src/vegalite/templates/scatter.ts
+++ b/packages/flint-js/src/vegalite/templates/scatter.ts
@@ -15,7 +15,17 @@ const isDiscreteType = (t: string | undefined) => t === 'nominal' || t === 'ordi
// fills most of its category band; a grouped (dodged) box fills most of its
// per-subgroup lane. The remainder becomes the gap between adjacent boxes.
const BOXPLOT_BAND_FILL = 0.7;
-const GROUPED_BOXPLOT_LANE_FILL = 0.85;
+// A dodged box should leave a legible gap between adjacent lanes, otherwise a
+// quartet of boxes reads as one solid multi-colour block. 0.7 keeps the box
+// substantial while opening a clear ~30%-of-lane channel between neighbours.
+const GROUPED_BOXPLOT_LANE_FILL = 0.7;
+// Half-width of the raw-observation jitter cloud, as a fraction of one lane.
+// 0.3 spreads the points across the middle ~60% of the lane, so the cloud sits
+// inside its box rather than spilling over the neighbouring one.
+const POINT_JITTER_FRACTION = 0.3;
+// Ink for the box skeleton once the box is hollow — dark and neutral, so the
+// median reads as a summary statistic rather than as another category.
+const SILHOUETTE_INK = '#2b2f36';
// Vega-Lite's default discrete position band scale reserves ~20% of each step as
// inter-band padding, so only ~80% of the step is usable drawing width. Grouped
// box sizing must use this usable width when splitting a band into sub-lanes,
@@ -206,7 +216,7 @@ export const boxplotDef: ChartTemplateDef = {
return {
axisFlags: { [result.axis]: { banded: true } },
resolvedTypes: result.resolvedTypes,
- paramOverrides: { defaultBandSize: 28 }, // box+whisker needs wider bands
+ paramOverrides: { defaultBandSize: 28, groupBandFillsLanes: true }, // box+whisker needs wider bands; grouped lanes each get full width
colorActsAsGroup, // dodge-by-color → budget band per category, shrink lanes
...(groupLaneCount ? { groupLaneCount } : {}),
};
@@ -214,25 +224,52 @@ export const boxplotDef: ChartTemplateDef = {
instantiate: (spec, ctx) => {
defaultBuildEncodings(spec, ctx.resolvedEncodings);
+ const props = ctx.chartProperties;
+ const layout = ctx.layout;
+ const hasDiscreteX = layout.xNominalCount > 0;
+ const hasDiscreteAxis = hasDiscreteX || layout.yNominalCount > 0;
+
+ // `showPoints` overlays every raw observation on top of the box. A box
+ // summarises a sample; at small n it can summarise almost nothing, so
+ // being able to show the sample is what makes the summary honest.
+ // Jitter needs a band to scatter within, hence the discrete-axis gate.
+ const showPoints = props?.showPoints === true && hasDiscreteAxis;
+
// Whisker convention + outlier visibility (design choices, not styling).
// whiskerMethod 'minmax' → whiskers span the full data range; VL draws
// no outlier points (they are inside the whiskers by definition).
// whiskerMethod 'iqr' (default) → Tukey 1.5×IQR whiskers; points beyond
// the fences render as outliers unless suppressed.
- const props = ctx.chartProperties;
const useMinMax = props?.whiskerMethod === 'minmax';
if (useMinMax) {
spec.mark = setMarkProp(spec.mark, 'extent', 'min-max');
}
// `showOutliers` defaults to true. With min-max whiskers there are no
- // outliers anyway, so hiding them is implicit.
- if (useMinMax || props?.showOutliers === false) {
+ // outliers anyway, so hiding them is implicit — and when every point is
+ // already drawn, the outlier marks would be duplicates.
+ if (useMinMax || props?.showOutliers === false || showPoints) {
spec.mark = setMarkProp(spec.mark, 'outliers', false);
}
- const layout = ctx.layout;
- const hasDiscreteX = layout.xNominalCount > 0;
- const hasDiscreteAxis = hasDiscreteX || layout.yNominalCount > 0;
+ // Drawing the sample inverts the visual hierarchy. Normally the box is
+ // the figure and there is nothing behind it; once every observation is
+ // on the page the sample becomes the figure and the box demotes to
+ // scaffolding over it. So the box gives up its fill and keeps only an
+ // outline, and the colour encoding moves to the points — encoding the
+ // group twice, in fill and in point colour, would just be redundant ink.
+ // `filled: false` is the switch that redirects the colour encoding from
+ // the box's fill to its stroke.
+ if (showPoints) {
+ spec.mark = setMarkProp(spec.mark, 'box', { filled: false, strokeWidth: 1.5 });
+ // The median rule is white by default so it reads against a filled
+ // box; over a hollow one it would disappear. It is the single
+ // most-read feature of a boxplot, so it gets the darkest ink at
+ // full strength (the boxplot theme dims marks to 0.7, which would
+ // let the point cloud show through it).
+ spec.mark = setMarkProp(spec.mark, 'median', {
+ color: SILHOUETTE_INK, strokeWidth: 2, opacity: 1,
+ });
+ }
// Grouped boxplots: a color field subdividing a categorical axis must
// dodge the boxes side-by-side (xOffset/yOffset), not overlay them at the
@@ -246,6 +283,13 @@ export const boxplotDef: ChartTemplateDef = {
let subgroups = 1;
let localSeparatorAxis: 'x' | 'y' | undefined;
let localSeparatorValues: Record[] = [];
+ // How far a box is pushed off its band centre, as an expression in
+ // offset-scale domain units (the [-0.5, 0.5] domain spans one band, so
+ // one lane is `1 / subgroups` wide). '0' = undodged, sits dead centre.
+ // The point overlay reuses this so a point always lands on its own box.
+ let laneOffsetExpr = '0';
+ // Transforms the lane expression depends on, replayed on the point layer.
+ let laneTransforms: Record[] = [];
const colorField = ctx.channelSemantics?.color?.field;
const axisField = hasDiscreteX
? ctx.channelSemantics?.x?.field
@@ -267,19 +311,44 @@ export const boxplotDef: ChartTemplateDef = {
// [-0.5, 0.5] range places each band's boxes centered, using
// only maxPerBand lanes. Native axis labels stay centered.
const maxPB = Math.max(1, plan.maxPerBand);
+ laneTransforms = [
+ { window: [{ op: 'dense_rank', as: '__laneIdx' }], groupby: [axisField], sort: [{ field: colorField, order: 'ascending' }] },
+ { joinaggregate: [{ op: 'distinct', field: colorField, as: '__localCount' }], groupby: [axisField] },
+ ];
+ laneOffsetExpr = `((datum.__laneIdx - 1) - (datum.__localCount - 1) / 2) / ${maxPB}`;
spec.encoding[offsetChannel] = {
field: '__off', type: 'quantitative',
scale: { domain: [-0.5, 0.5] }, axis: null,
};
spec.transform = [
...(spec.transform ?? []),
- { window: [{ op: 'dense_rank', as: '__laneIdx' }], groupby: [axisField], sort: [{ field: colorField, order: 'ascending' }] },
- { joinaggregate: [{ op: 'distinct', field: colorField, as: '__localCount' }], groupby: [axisField] },
- { calculate: `((datum.__laneIdx - 1) - (datum.__localCount - 1) / 2) / ${maxPB}`, as: '__off' },
+ ...laneTransforms,
+ { calculate: laneOffsetExpr, 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 if (showPoints) {
+ // Global lanes, with a point overlay. A *nominal* offset can
+ // carry a lane but not the extra jitter the points need, and
+ // one channel admits one scale — so resolve the lane index in
+ // the spec instead and let both layers share one quantitative
+ // offset. Lane order follows the declared colour sort, so the
+ // lanes still match the legend.
+ const laneOrder = (Array.isArray(colorEnc.sort) && colorEnc.sort.length > 0
+ ? colorEnc.sort
+ : [...new Set((ctx.fullTable ?? ctx.table).map((row) => row[colorField]))].sort()
+ ).map((value: unknown) => String(value));
+ subgroups = Math.max(1, laneOrder.length);
+ laneOffsetExpr = `(indexof(${JSON.stringify(laneOrder)}, toString(datum[${JSON.stringify(colorField)}])) - ${(subgroups - 1) / 2}) / ${subgroups}`;
+ spec.encoding[offsetChannel] = {
+ field: '__off', type: 'quantitative',
+ scale: { domain: [-0.5, 0.5] }, axis: null,
+ };
+ spec.transform = [
+ ...(spec.transform ?? []),
+ { calculate: laneOffsetExpr, as: '__off' },
+ ];
} else {
// Global: a fixed lane per distinct color across all bands.
const offsetEnc: Record = { field: colorEnc.field, type: 'nominal' };
@@ -306,22 +375,73 @@ export const boxplotDef: ChartTemplateDef = {
}
}
+ let separatorLayer: Record | undefined;
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,
- },
+ separatorLayer = {
+ 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,
+ },
+ },
+ };
+ }
+
+ // The raw-observation overlay. Points ride on the same offset channel as
+ // the boxes: lane position (so a point sits on its own box) plus jitter
+ // (so coincident values do not stack into one dot). The offset scale's
+ // [-0.5, 0.5] domain maps onto the band, which keeps the cloud centred
+ // whatever the band width works out to — measuring jitter in pixels
+ // silently drifts off-centre when the layout changes the step.
+ let pointLayer: Record | undefined;
+ if (showPoints) {
+ const offsetChannel = hasDiscreteX ? 'xOffset' : 'yOffset';
+ const lanePitch = ((hasDiscreteX ? layout.xStep : layout.yStep) * USABLE_BAND_FRACTION) / subgroups;
+ // `size` is point AREA in px²; keep the glyph well inside its lane.
+ const pointSize = Math.max(8, Math.min(30, Math.round(lanePitch * 0.6)));
+ const jitter = `(random() * 2 - 1) * ${(POINT_JITTER_FRACTION / subgroups).toFixed(5)}`;
+ pointLayer = {
+ transform: [
+ ...laneTransforms,
+ { calculate: laneOffsetExpr === '0' ? jitter : `${laneOffsetExpr} + ${jitter}`, as: '__off' },
+ ],
+ mark: {
+ // Points now carry the colour encoding (see the silhouette
+ // note above), so they are the one mark spending saturated
+ // ink. Slight transparency lets dense regions read as
+ // density; the hairline white halo keeps individual points
+ // countable where they overlap.
+ type: 'point', filled: true, size: pointSize,
+ opacity: 0.7, stroke: '#ffffff', strokeWidth: 0.5,
+ },
+ encoding: {
+ ...(spec.encoding.x ? { x: JSON.parse(JSON.stringify(spec.encoding.x)) } : {}),
+ ...(spec.encoding.y ? { y: JSON.parse(JSON.stringify(spec.encoding.y)) } : {}),
+ ...(spec.encoding.color
+ ? { color: JSON.parse(JSON.stringify(spec.encoding.color)) }
+ : {}),
+ [offsetChannel]: {
+ field: '__off', type: 'quantitative',
+ scale: { domain: [-0.5, 0.5] }, axis: null,
},
},
+ };
+ }
+
+ if (separatorLayer || pointLayer) {
+ const boxLayer: Record = { mark: spec.mark, encoding: spec.encoding };
+ if (spec.transform) boxLayer.transform = spec.transform;
+ // The hollow box goes on top of the cloud: it costs almost no ink to
+ // occlude, and the quartile edges and median have to stay crisp
+ // exactly where the points are densest.
+ spec.layer = [
+ ...(separatorLayer ? [separatorLayer] : []),
+ ...(pointLayer ? [pointLayer] : []),
boxLayer,
];
delete spec.mark;
@@ -338,11 +458,24 @@ export const boxplotDef: ChartTemplateDef = {
],
defaultValue: 'iqr',
},
+ {
+ key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false,
+ // Jitter needs a band to scatter within, so this is only meaningful
+ // once one position axis is discrete.
+ check: (ctx) => ({
+ applicable: isDiscreteType(ctx.channelSemantics?.x?.type)
+ || isDiscreteType(ctx.channelSemantics?.y?.type),
+ }),
+ },
{
key: 'showOutliers', label: 'Outliers', type: 'binary', defaultValue: true,
// Outliers exist only with Tukey whiskers; min–max whiskers absorb
- // every point, so the toggle is irrelevant there.
- check: (ctx) => ({ applicable: ctx.chartProperties?.whiskerMethod !== 'minmax' }),
+ // every point, so the toggle is irrelevant there. And once every
+ // observation is drawn, the outlier marks are just duplicates.
+ check: (ctx) => ({
+ applicable: ctx.chartProperties?.whiskerMethod !== 'minmax'
+ && ctx.chartProperties?.showPoints !== true,
+ }),
},
{
key: 'dodge', label: 'Dodge', type: 'discrete',
diff --git a/packages/flint-js/src/vegalite/templates/slope.ts b/packages/flint-js/src/vegalite/templates/slope.ts
index bfd714ac..2530cf9c 100644
--- a/packages/flint-js/src/vegalite/templates/slope.ts
+++ b/packages/flint-js/src/vegalite/templates/slope.ts
@@ -25,7 +25,7 @@
* shows points at both ends.
*/
-import { ChartTemplateDef } from '../../core/types';
+import { ChartTemplateDef, ChartPropertyDef } from '../../core/types';
import { resolveDiscreteType } from '../../core/axis-detection';
import { defaultBuildEncodings } from './utils';
@@ -82,8 +82,12 @@ export const slopeChartDef: ChartTemplateDef = {
paramOverrides: {
// Spread the two periods well apart and keep the plot from being
// squeezed tall: a wide band step + no series-count vertical
- // stretch yields the classic balanced slopegraph framing.
+ // stretch yields the classic balanced slopegraph framing. The
+ // band step is also a floor (`minBandStep`): a compact house may
+ // spread the two columns wider, but not pack them so close the
+ // slopes go near-vertical and the end labels have no room.
defaultBandSize: 120,
+ minBandStep: 120,
continuousMarkCrossSection: { x: 0, y: 0, seriesCountAxis: 'auto' },
facetAspectRatioResistance: 0.4,
},
@@ -114,7 +118,9 @@ export const slopeChartDef: ChartTemplateDef = {
// Inset the two period bands from the plot edges so the end points and
// their value labels are not clipped (classic slopegraph framing).
- xEnc.scale = { ...xEnc.scale, padding: 0.4 };
+ const labelled = ctx.chartProperties?.showText === true;
+ const named = labelled && ctx.chartProperties?.showSeriesInLabel === true;
+ xEnc.scale = { ...xEnc.scale, padding: named ? 0.75 : labelled ? 0.55 : 0.4 };
// Give the value axis a little breathing room (in pixels) so the
// extreme top / bottom end-point markers — common with zero-crossing
@@ -123,5 +129,75 @@ export const slopeChartDef: ChartTemplateDef = {
// data rather than anchoring at zero — matching the ECharts / Chart.js
// slope templates and classic slopegraph convention.
yEnc.scale = { ...yEnc.scale, zero: false, nice: true, padding: 12 };
+
+ // A slopegraph is a table that happens to be drawn. Its two columns of
+ // numbers are the point of it — the line only says which way the pair
+ // moved — so printing the values at both ends is the chart's own job,
+ // not an annotation added over it. And a value with no name attached is
+ // half a row: `showSeriesInLabel` puts the two back together and makes
+ // the colour legend redundant.
+ const props = ctx.chartProperties;
+ if (props?.showText === true) {
+ const periods = orderedDistinct(ctx.table, xEnc.field);
+ if (periods.length >= 2) {
+ const first = periods[0];
+ const last = periods[periods.length - 1];
+ const seriesField = ctx.channelSemantics?.color?.field
+ ?? ctx.channelSemantics?.detail?.field;
+ const withSeries = props.showSeriesInLabel === true && !!seriesField;
+ // Names on the marks make the colour legend redundant: it would
+ // only repeat, in a second place, the words already printed at
+ // each line's ends. Drop it so the plot is the whole story.
+ if (withSeries && (spec.encoding as any)?.color) {
+ (spec.encoding as any).color = {
+ ...(spec.encoding as any).color, legend: null,
+ };
+ }
+ const fmt = props.labelFormat ?? '.3~s';
+ const valueExpr = `format(datum[${JSON.stringify(yEnc.field)}], ${JSON.stringify(fmt)})`;
+ const labelExpr = withSeries
+ ? `datum[${JSON.stringify(seriesField)}] + ' ' + ${valueExpr}`
+ : valueExpr;
+ const endLayer = (period: unknown, align: 'left' | 'right') => ({
+ transform: [
+ { filter: { field: xEnc.field, equal: period as any } },
+ { calculate: labelExpr, as: '__slopeLabel' },
+ ],
+ mark: {
+ type: 'text',
+ align,
+ baseline: 'middle',
+ dx: align === 'right' ? -8 : 8,
+ fontSize: 11,
+ },
+ encoding: {
+ ...JSON.parse(JSON.stringify(spec.encoding)),
+ text: { field: '__slopeLabel', type: 'nominal' },
+ },
+ });
+ const lineLayer: Record = { mark: spec.mark, encoding: spec.encoding };
+ if (spec.transform) lineLayer.transform = spec.transform;
+ spec.layer = [
+ lineLayer,
+ endLayer(first, 'right'),
+ endLayer(last, 'left'),
+ ];
+ delete spec.mark;
+ delete spec.encoding;
+ delete spec.transform;
+ }
+ }
},
+ properties: [
+ {
+ key: 'showText', label: 'Values', type: 'binary', defaultValue: false,
+ },
+ {
+ key: 'showSeriesInLabel', label: 'Name in label', type: 'binary', defaultValue: false,
+ // A name only goes in the label when there is a name to put there.
+ check: (ctx) => ({
+ applicable: Boolean(ctx.encodings?.color?.field || ctx.encodings?.detail?.field),
+ }),
+ },
+ ] as ChartPropertyDef[],
};
diff --git a/packages/flint-js/src/vegalite/templates/utils.ts b/packages/flint-js/src/vegalite/templates/utils.ts
index 4f9a7095..8e461699 100644
--- a/packages/flint-js/src/vegalite/templates/utils.ts
+++ b/packages/flint-js/src/vegalite/templates/utils.ts
@@ -79,6 +79,16 @@ export function setMarkProp(mark: any, key: string, value: any): any {
return { ...mark, [key]: value };
}
+/**
+ * Marks whose size came from the coarse coverage estimate in
+ * `applyPointSizeScaling`, which runs at build time against an assumed plot
+ * and the whole table. A theme knows the plot it actually got and how many
+ * panels the rows are spread over, so where both have an opinion the theme's
+ * is the better-informed one — but only for the marks this rule sized, never
+ * for a mark a template fitted to a lane.
+ */
+export const coverageSizedMarks = new WeakSet