From 5809053b8eb3071789beee424830e1e273579fa7 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 12 Oct 2026 06:25:29 +0530 Subject: [PATCH 1/4] feat(highcharts): add Highcharts rendering backend Add assembleHighcharts() as a fifth rendering backend alongside Vega-Lite, ECharts, Chart.js, and Plotly. The backend reuses the shared core pipeline (resolveChannelSemantics, computeLayout, filterOverflow) and diverges only at Phase 2 template instantiation. 13 chart templates: Bar, Grouped Bar, Stacked Bar, Line, Area, Scatter, Pie, Doughnut, Histogram, Radar, Heatmap, Waterfall, Sunburst. - highcharts/ assembler, layout application, palette selection - validateChart integration for the highcharts backend - MCP render path via headless Chrome (puppeteer-core) - Site HighchartsView + backend picker entry - Test-data generators registered in TEST_GENERATORS - Generated reference doc (docs/reference-highcharts.md) - Docs: README, DEVELOPMENT, api-reference, adding-a-backend, adding-a-chart-template, test_plan, agent skill --- CHANGELOG.md | 8 + README.md | 19 +- agent-skills/flint-chart-author/SKILL.md | 10 +- docs/DEVELOPMENT.md | 1 + docs/README.md | 4 +- docs/adding-a-backend.md | 2 +- docs/adding-a-chart-template.md | 4 +- docs/api-reference.md | 8 +- docs/architecture.md | 5 +- docs/overview.md | 5 +- docs/reference-highcharts.md | 142 ++++ docs/test_plan.md | 2 + docs/tutorials/exploring-data.md | 4 +- package-lock.json | 45 +- packages/flint-js/package.json | 9 + packages/flint-js/src/highcharts/assemble.ts | 414 ++++++++++++ packages/flint-js/src/highcharts/colormap.ts | 155 +++++ packages/flint-js/src/highcharts/index.ts | 12 + .../src/highcharts/instantiate-spec.ts | 220 ++++++ .../flint-js/src/highcharts/interactive.ts | 264 ++++++++ .../flint-js/src/highcharts/templates/bar.ts | 495 ++++++++++++++ .../src/highcharts/templates/heatmap.ts | 103 +++ .../src/highcharts/templates/histogram.ts | 107 +++ .../src/highcharts/templates/index.ts | 52 ++ .../flint-js/src/highcharts/templates/line.ts | 234 +++++++ .../flint-js/src/highcharts/templates/pie.ts | 157 +++++ .../src/highcharts/templates/radar.ts | 114 ++++ .../src/highcharts/templates/scatter.ts | 102 +++ .../src/highcharts/templates/sunburst.ts | 106 +++ .../src/highcharts/templates/utils.ts | 220 ++++++ .../src/highcharts/templates/waterfall.ts | 119 ++++ packages/flint-js/src/index.ts | 3 + packages/flint-js/src/interactive/index.ts | 12 + packages/flint-js/src/interactive/types.ts | 2 +- .../src/test-data/highcharts-tests.ts | 626 ++++++++++++++++++ packages/flint-js/src/test-data/index.ts | 15 + packages/flint-js/src/validate/index.ts | 45 +- .../flint-js/tests/highcharts-backend.test.ts | 390 +++++++++++ packages/flint-js/tests/smoke.test.ts | 29 + packages/flint-js/tsup.config.ts | 3 +- .../assets/flint-chart-author.SKILL.md | 10 +- packages/flint-mcp/package.json | 2 + packages/flint-mcp/src/cli.ts | 4 +- packages/flint-mcp/src/render/highcharts.ts | 74 +++ packages/flint-mcp/src/render/index.ts | 11 + packages/flint-mcp/src/render/types.ts | 2 +- packages/flint-mcp/src/tools/list.ts | 2 + packages/flint-mcp/src/tools/schemas.ts | 4 +- packages/flint-mcp/tests/server.test.ts | 2 +- scripts/gen-chart-reference.ts | 11 + site/README.md | 2 +- site/package.json | 3 +- site/src/components/HighchartsView.tsx | 162 +++++ .../components/InteractiveHighchartsView.tsx | 61 ++ site/src/playground/OverflowViewportLab.tsx | 17 +- site/src/routes/ChartWall.tsx | 1 + site/src/routes/Editor.tsx | 6 + site/src/shared/docs-catalog.ts | 6 + site/src/shared/supported-backends.ts | 10 +- 59 files changed, 4590 insertions(+), 67 deletions(-) create mode 100644 docs/reference-highcharts.md create mode 100644 packages/flint-js/src/highcharts/assemble.ts create mode 100644 packages/flint-js/src/highcharts/colormap.ts create mode 100644 packages/flint-js/src/highcharts/index.ts create mode 100644 packages/flint-js/src/highcharts/instantiate-spec.ts create mode 100644 packages/flint-js/src/highcharts/interactive.ts create mode 100644 packages/flint-js/src/highcharts/templates/bar.ts create mode 100644 packages/flint-js/src/highcharts/templates/heatmap.ts create mode 100644 packages/flint-js/src/highcharts/templates/histogram.ts create mode 100644 packages/flint-js/src/highcharts/templates/index.ts create mode 100644 packages/flint-js/src/highcharts/templates/line.ts create mode 100644 packages/flint-js/src/highcharts/templates/pie.ts create mode 100644 packages/flint-js/src/highcharts/templates/radar.ts create mode 100644 packages/flint-js/src/highcharts/templates/scatter.ts create mode 100644 packages/flint-js/src/highcharts/templates/sunburst.ts create mode 100644 packages/flint-js/src/highcharts/templates/utils.ts create mode 100644 packages/flint-js/src/highcharts/templates/waterfall.ts create mode 100644 packages/flint-js/src/test-data/highcharts-tests.ts create mode 100644 packages/flint-js/tests/highcharts-backend.test.ts create mode 100644 packages/flint-mcp/src/render/highcharts.ts create mode 100644 site/src/components/HighchartsView.tsx create mode 100644 site/src/components/InteractiveHighchartsView.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 42439201..95e93992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `semantic_types` labels surface as `unknown_semantic_type` warnings, and `isRegistered` / `getRegisteredTypes` are exported from `flint-chart/core` ([#104](https://github.com/microsoft/flint-chart/issues/104)). +- Highcharts backend. The same Flint input compiles to a Highcharts + `option` via `assembleHighcharts(input)` (also from the new + `flint-chart/highcharts` subpath): bar, stacked bar, grouped bar, + line, area, pie, doughnut, scatter, histogram, and radar templates + with native series types, datetime axes, and per-panel faceting + (`_facet`). `validateChart` accepts `highcharts`, and the MCP + server and site editor render it. Guide: + `docs/reference-highcharts.md`. ### Changed diff --git a/README.md b/README.md index 72648126..75a951e1 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,16 @@ Rather than requiring agents to hardcode verbose parameters for scales, axes, spacing, labels, and layout, Flint derives those decisions from a high-level semantic specification, the data, and an optional visual theme (e.g., New York Times, Economist, Swiss, and McKinsey). A compact spec compiles to native -[Vega-Lite](https://vega.github.io/vega-lite/), -[ECharts](https://echarts.apache.org/), -[Chart.js](https://www.chartjs.org/), -[Plotly](https://plotly.com/javascript/) specs, or editable Excel charts. + [Vega-Lite](https://vega.github.io/vega-lite/), + [ECharts](https://echarts.apache.org/), + [Chart.js](https://www.chartjs.org/), + [Highcharts](https://www.highcharts.com/), + [Plotly](https://plotly.com/javascript/) specs, or editable Excel charts. This repo contains two main components: - **`flint-chart`**: a JavaScript/TypeScript library that compiles the same - Flint input into Vega-Lite, ECharts, Chart.js, Plotly, or Excel-native output. + Flint input into Vega-Lite, ECharts, Chart.js, Highcharts, Plotly, or Excel-native output. - **`flint-chart-mcp`**: an MCP server that lets agents create, validate, and render charts directly from a chat or coding environment. @@ -67,6 +68,7 @@ For a local MCP server (recommended for large datasets), check the [Vega-Lite](https://vega.github.io/vega-lite/), [ECharts](https://echarts.apache.org/), [Chart.js](https://www.chartjs.org/), + [Highcharts](https://www.highcharts.com/), [Plotly](https://plotly.com/javascript/), and native Excel charts. - **Agent-ready chart authoring.** The MCP server gives agents Flint tools and chart guidance so they can choose a template, validate it, and open an @@ -132,10 +134,11 @@ const spec = assembleVegaLite({ Swap the backend without changing the input shape: ```ts -import { assembleECharts, assembleChartjs, assemblePlotly, assembleExcel } from 'flint-chart'; +import { assembleECharts, assembleChartjs, assembleHighcharts, assemblePlotly, assembleExcel } from 'flint-chart'; const echartsOption = assembleECharts(input); const chartjsConfig = assembleChartjs(input); +const highchartsOption = assembleHighcharts(input); const plotlyFigure = assemblePlotly(input); const excelArtifact = assembleExcel(input); ``` @@ -203,7 +206,8 @@ reference. 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 +[Chart.js](docs/reference-chartjs.md), [Highcharts](docs/reference-highcharts.md), +[Plotly](docs/reference-plotly.md), and [Excel](docs/reference-excel.md), plus the [live editor](https://microsoft.github.io/flint-chart/#/editor) for more library examples. @@ -238,6 +242,7 @@ flint-chart/ │ │ ├── vegalite/ Vega-Lite backend │ │ ├── echarts/ ECharts backend │ │ ├── chartjs/ Chart.js backend +│ │ ├── highcharts/ Highcharts backend │ │ ├── plotly/ Plotly backend │ │ ├── excel/ native Excel backend │ │ ├── gallery/ gallery assembly and generated references diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index dc8b0761..57610d3f 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -10,7 +10,7 @@ description: "Use when: the user asks to make or render charts with flint-chart, Your output is the **spec**: the `chart_spec` and `semantic_types` of a `ChartAssemblyInput`. You reference data columns **by name**. The host passes the resulting input to `assembleVegaLite`, `assembleECharts`, -or `assembleChartjs` to get a backend spec. +`assembleChartjs`, or `assembleHighcharts` to get a backend spec. **You write the input spec, not the output spec.** And critically: @@ -67,16 +67,18 @@ npm install flint-chart npm install vega vega-lite vega-embed # browser Vega-Lite rendering npm install echarts # ECharts rendering npm install chart.js # Chart.js rendering +npm install highcharts # Highcharts rendering ``` Then compile with the requested backend: ```ts -import { assembleChartjs, assembleECharts, assembleVegaLite } from 'flint-chart'; +import { assembleChartjs, assembleECharts, assembleHighcharts, assembleVegaLite } from 'flint-chart'; const vegaLiteSpec = assembleVegaLite(input); const echartsOption = assembleECharts(input); const chartjsConfig = assembleChartjs(input); +const highchartsOption = assembleHighcharts(input); ``` Python support is planned for a later release. Until the PyPI package is @@ -123,7 +125,7 @@ Use the binding mode that matches the runtime. Do not mix them. 3. **Generated application or notebook code: bind runtime variables.** If the user asks you to add Flint to code, write normal data-loading code first and pass a real runtime value, e.g. `data: { values: rows }`, to - `assembleVegaLite`, `assembleECharts`, or `assembleChartjs`. This variable + `assembleVegaLite`, `assembleECharts`, `assembleChartjs`, or `assembleHighcharts`. This variable pattern is for generated code, not for MCP tool calls. For spec-only answers, return the `semantic_types` and `chart_spec` pieces and @@ -376,6 +378,8 @@ support a subset (verify if targeting a non-VL backend): - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, Lollipop, Bump, Combo, Line, Area, Range Area, Pie, Doughnut, Histogram, Radar, Rose, Slope, Connected Scatter. +- **Highcharts** supports: Scatter Plot, Bar, Grouped Bar, Stacked Bar, + Line, Area, Pie, Doughnut, Histogram, Radar, Heatmap, Waterfall, Sunburst. You do not need to call the library or inspect its source to author the input — pick from this table. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0aa9c3de..d95986fa 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -40,6 +40,7 @@ flint-chart/ │ │ ├── src/vegalite/ Vega-Lite backend │ │ ├── src/echarts/ ECharts backend │ │ ├── src/chartjs/ Chart.js backend +│ │ ├── src/highcharts/ Highcharts backend │ │ └── src/test-data/ gallery fixtures │ ├── flint-py/ Python port preview (PyPI package planned later) │ └── flint-mcp/ npm package `flint-chart-mcp` diff --git a/docs/README.md b/docs/README.md index 6860cc1b..2a0ffb30 100644 --- a/docs/README.md +++ b/docs/README.md @@ -725,7 +725,7 @@ Abstract channel for two distinct visual mappings: ### `ChannelSemantics` Phase 0 output for a single channel. Combines user input with resolved -decisions. This is the central IR — all four backends read the same +decisions. This is the central IR — all five backends read the same `ChannelSemantics` record. ```typescript @@ -1050,7 +1050,7 @@ value sort when the largest or smallest categories should be retained. | File | Lines | Role | |------|-------|------| -| `index.ts` | 60 | Public API re-exports (all 4 backends) | +| `index.ts` | 60 | Public API re-exports (all 5 backends) | **Total: ~21,000 lines** across 87 `.ts` files (excluding test-data). diff --git a/docs/adding-a-backend.md b/docs/adding-a-backend.md index c0afb56d..226959b0 100644 --- a/docs/adding-a-backend.md +++ b/docs/adding-a-backend.md @@ -113,7 +113,7 @@ Register in `templates/index.ts`: import defs, add them to the category map, and - **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. +- **Renderers:** only add a new React view (`site/src/components/`) when the spec format cannot reuse `VegaLiteView`, `EChartsView`, `ChartjsView`, or `HighchartsView`. `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. diff --git a/docs/adding-a-chart-template.md b/docs/adding-a-chart-template.md index fbb6b50b..8280678a 100644 --- a/docs/adding-a-chart-template.md +++ b/docs/adding-a-chart-template.md @@ -28,7 +28,7 @@ Pick only the channels the mark actually uses. Copy a similar template as a star | Line / area | `vegalite/templates/line.ts` | | Radial | `vegalite/templates/pie.ts` | -ECharts and Chart.js use the same `ChartTemplateDef` interface under their own `templates/` folders. +ECharts, Chart.js, and Highcharts use the same `ChartTemplateDef` interface under their own `templates/` folders. --- @@ -125,7 +125,7 @@ Review formatting, layout stretch, legends, and facet behavior across 3–6 repr # §5 Cross-backend parity -The user-facing contract is that the same `chartType` string should work across `assembleVegaLite`, `assembleECharts`, and `assembleChartjs` **when templates exist**. In practice: +The user-facing contract is that the same `chartType` string should work across `assembleVegaLite`, `assembleECharts`, `assembleChartjs`, and `assembleHighcharts` **when templates exist**. In practice: - Port to backends you need immediately; file follow-ups for the rest. - `site/src/shared/supported-backends.ts` filters chart types per backend registry. A Vega-Lite-only template will not appear in ECharts until it is registered there too. diff --git a/docs/api-reference.md b/docs/api-reference.md index ef29541f..067d4d69 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -58,6 +58,7 @@ import { assembleVegaLite, assembleECharts, assembleChartjs, + assembleHighcharts, assemblePlotly, assembleExcel, } from 'flint-chart'; @@ -65,6 +66,7 @@ import { const vlSpec = assembleVegaLite(input); const ecSpec = assembleECharts(input); const cjsSpec = assembleChartjs(input); +const hcSpec = assembleHighcharts(input); const plFig = assemblePlotly(input); const xlSpec = assembleExcel(input); ``` @@ -74,10 +76,11 @@ const xlSpec = assembleExcel(input); | `assembleVegaLite` | Vega-Lite JSON spec | | `assembleECharts` | ECharts `option` object | | `assembleChartjs` | Chart.js configuration | +| `assembleHighcharts` | Highcharts `option` object | | `assemblePlotly` | Plotly.js `{ data, layout }` figure | | `assembleExcel` | Native Excel chart artifact for Office.js | -If a backend does not support a `chartType`, the assembler throws before render. Check support with `vlGetTemplateDef`, `ecGetTemplateDef`, `cjsGetTemplateDef`, `plGetTemplateDef`, or `excelGetTemplateDef`. +If a backend does not support a `chartType`, the assembler throws before render. Check support with `vlGetTemplateDef`, `ecGetTemplateDef`, `cjsGetTemplateDef`, `hcGetTemplateDef`, `plGetTemplateDef`, or `excelGetTemplateDef`. ## Excel runtime and code generation @@ -337,7 +340,7 @@ if (!result.valid) { | `validateSemanticTypes(semantic_types)` | `unknown_semantic_type` warnings for labels not in the type registry (also included by `validateChart`) | | `assembleForBackend(backend, input, options?)` | Assemble and split out `_warnings` / `_width` / `_height` | | `stripPrivateKeys(spec)` | Remove Flint's `_`-prefixed metadata from a spec | -| `VALIDATION_BACKENDS` | Runtime list of accepted backends (`vegalite`, `echarts`, `chartjs`, `plotly`) | +| `VALIDATION_BACKENDS` | Runtime list of accepted backends (`vegalite`, `echarts`, `chartjs`, `highcharts`, `plotly`) | `options.maxDataRows` (default 100,000) and `options.maxCanvasDim` (default 4000) cap input size. Inline `data.values` are required — resolve `data.url` @@ -376,6 +379,7 @@ Inspect `_warnings` or `ChartWarning` arrays in integration code to surface trun | `flint-chart/vegalite` | VL templates and `assembleVegaLite` | | `flint-chart/echarts` | ECharts templates and `assembleECharts` | | `flint-chart/chartjs` | Chart.js templates and `assembleChartjs` | +| `flint-chart/highcharts` | Highcharts templates and `assembleHighcharts` | | `flint-chart/validate` | `validateChart` and input validation helpers | | `flint-chart/test-data` | Gallery generators (`TEST_GENERATORS`) | diff --git a/docs/architecture.md b/docs/architecture.md index b738faf9..36bd93a3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,10 +39,10 @@ Most design logic lives in Stages 1–2 and is identical across backends. |-------|------------|----------------|-------------| | **1. Compiler frontend** | Resolve semantic context | Phase 0 — `resolveChannelSemantics()` | `ChannelSemantics` per channel | | **2. Optimizer** | Fit layout to canvas | Phase 1 — `computeLayout()`, `filterOverflow()` | `LayoutResult`, truncated data | -| **3. Code generator** | Emit backend-native output | Phase 2 — `build*Encodings()`, `template.instantiate()` | VL / EC / CJS / Plotly spec or Excel artifact | +| **3. Code generator** | Emit backend-native output | Phase 2 — `build*Encodings()`, `template.instantiate()` | VL / EC / CJS / Highcharts / Plotly spec or Excel artifact | ```text -assembleVegaLite(input) // or assembleECharts, assembleChartjs, assemblePlotly, assembleExcel +assembleVegaLite(input) // or assembleECharts, assembleChartjs, assembleHighcharts, assemblePlotly, assembleExcel │ ▼ ══ STAGE 1 — COMPILER FRONTEND (core/) ═════════════════════════ @@ -199,6 +199,7 @@ packages/flint-js/src/ ├── vegalite/ Stage 3 — Vega-Lite templates + assembleVegaLite ├── echarts/ Stage 3 — ECharts templates + assembleECharts ├── chartjs/ Stage 3 — Chart.js templates + assembleChartjs +├── highcharts/ Stage 3 — Highcharts templates + assembleHighcharts ├── excel/ Stage 3 — native Excel chart planning + Office.js generation └── test-data/ gallery fixtures (TEST_GENERATORS) diff --git a/docs/overview.md b/docs/overview.md index 622139df..0e37b9cd 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1,6 +1,6 @@ # Overview -**Flint** is a semantics-driven intermediate language (IL) for data visualization. You declare what each field *means* and the chart you want; the compiler derives scales, axes, aggregation, formatting, layout, and color, then emits Vega-Lite, ECharts, Chart.js, Plotly, or a native Excel chart artifact. +**Flint** is a semantics-driven intermediate language (IL) for data visualization. You declare what each field *means* and the chart you want; the compiler derives scales, axes, aggregation, formatting, layout, and color, then emits Vega-Lite, ECharts, Chart.js, Highcharts, Plotly, or a native Excel chart artifact. If you're new to Flint, start with [Getting started](/documentation/getting-started), then come back here for the architecture and API map. @@ -105,6 +105,7 @@ Semantic types use a three-level hierarchy. Details: [Semantic Type](/documentat | `assembleVegaLite(input)` | Vega-Lite v6 spec | | `assembleECharts(input)` | ECharts `option` | | `assembleChartjs(input)` | Chart.js config | +| `assembleHighcharts(input)` | Highcharts `option` | The same input compiles to every supported backend. Stages 1–2 (semantics + layout) live in shared `core/`; only Stage 3 (template instantiation) is library-specific. @@ -135,7 +136,7 @@ Pipeline detail: [Architecture](/documentation/architecture). | Section | Pages | |---------|-------| | **Language design** | [Architecture](/documentation/architecture), [Semantic Type](/documentation/semantic-types), [Auto Layout Algorithm](/documentation/layout-model), [API reference](/documentation/api-reference) | -| **Chart reference** | [Vega-Lite charts](/documentation/reference-vegalite), [ECharts charts](/documentation/reference-echarts), [Chart.js charts](/documentation/reference-chartjs), [Plotly charts](/documentation/reference-plotly), [Excel charts](/documentation/reference-excel) | +| **Chart reference** | [Vega-Lite charts](/documentation/reference-vegalite), [ECharts charts](/documentation/reference-echarts), [Chart.js charts](/documentation/reference-chartjs), [Highcharts charts](/documentation/reference-highcharts), [Plotly charts](/documentation/reference-plotly), [Excel charts](/documentation/reference-excel) | | **Development** | [Development guide](/documentation/development), [Extending semantic types](/documentation/adding-a-semantic-type), [Extending backends](/documentation/adding-a-backend), [Extending chart templates](/documentation/adding-a-chart-template) | --- diff --git a/docs/reference-highcharts.md b/docs/reference-highcharts.md new file mode 100644 index 00000000..c18aa69f --- /dev/null +++ b/docs/reference-highcharts.md @@ -0,0 +1,142 @@ +# Highcharts chart reference + +> This page is generated from the live chart-template registry (`scripts/gen-chart-reference.ts`). Do not edit it by hand — run `npm run gen:reference`. + +The Highcharts backend compiles to a Highcharts options object (`{ chart, xAxis, yAxis, series, tooltip, legend, colors }`) for SVG-rendered, interactive charts. It covers the common chart families with native Highcharts series types and datetime axes. + +## What this page covers + +This reference lists the 13 chart types currently supported by the Highcharts backend, grouped into 7 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. + +Use the chart type name exactly as shown in `chart_spec.chartType`. + +## How to set encodings and options + +Set encodings in `chart_spec.encodings` and chart-specific options in `chart_spec.chartProperties`. Option keys match the parameter names below: + +```jsonc +{ + "chartType": "Bar Chart", + "encodings": { "x": { "field": "category" }, "y": { "field": "value" } }, + "chartProperties": { "cornerRadius": 4, "stackMode": "normalize" } +} +``` + +The **Availability** column shows whether a parameter is `always` available or `conditional`, meaning it appears only when the data and encodings make it relevant. For example, log-scale controls appear only on wide-range axes. Non-applicable parameters are safe to pass; the assembler ignores them. + +## Scatter & Point + +### ![](chart-icon-scatter.svg) Scatter Plot + +**Encoding channels:** `x`, `y`, `color`, `size`, `opacity`, `column`, `row` + +_No template-specific parameters._ + +## Bar + +### ![](chart-icon-column.svg) Bar Chart + +**Encoding channels:** `x`, `y`, `color`, `opacity`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `cornerRadius` | number | 0 – 15 (step 1) | `0` | always | Corner radius for supported marks. | + +### ![](chart-icon-column-grouped.svg) Grouped Bar Chart + +**Encoding channels:** `x`, `y`, `group`, `color`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge | + +### ![](chart-icon-column-stacked.svg) Stacked Bar Chart + +**Encoding channels:** `x`, `y`, `color`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `stackMode` | choice | `normal` (Count), `percent` (Percent (100%)) | `normal` | conditional | Stacking strategy for overlapping series. | + +### ![](chart-icon-histogram.svg) Histogram + +**Encoding channels:** `x`, `color`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `binCount` | number | 3 – 40 (step 1) | `10` | always | Maximum bin cap; Auto lets the backend choose. | + +## Line & Area + +### ![](chart-icon-line.svg) Line Chart + +**Encoding channels:** `x`, `y`, `color`, `opacity`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `interpolate` | choice | `linear` (Straight), `monotone` (Smooth (spline)) | `linear` | always | Line or area interpolation method. | + +### ![](chart-icon-area.svg) Area Chart + +**Encoding channels:** `x`, `y`, `color`, `opacity`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `interpolate` | choice | `linear` (Straight), `monotone` (Smooth (spline)) | `linear` | always | Line or area interpolation method. | + +## Part-to-Whole + +### ![](chart-icon-pie.svg) Pie Chart + +**Encoding channels:** `size`, `color`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `innerRadius` | number | 0 – 60 (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 | + +### ![](chart-icon-doughnut.svg) Doughnut Chart + +**Encoding channels:** `size`, `color`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `innerRadius` | number | 20 – 80 (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 | + +### ![](chart-icon-sunburst.svg) Sunburst Chart + +**Encoding channels:** `color`, `group`, `detail`, `size`, `column`, `row` + +_No template-specific parameters._ + +## Polar + +### ![](chart-icon-radar.svg) Radar Chart + +**Encoding channels:** `x`, `y`, `color`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `filled` | choice | `true` (Filled), `false` (Outline only) | `true` | always | Fill the enclosed radar area. | + +## Statistical + +### ![](chart-icon-heat-map.svg) Heatmap + +**Encoding channels:** `x`, `y`, `color`, `column`, `row` + +_No template-specific parameters._ + +## Other + +### ![](chart-icon-waterfall.svg) Waterfall Chart + +**Encoding channels:** `x`, `y`, `color`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `totals` | choice | `auto` (Auto (data-aware)), `first` (First only), `last` (Last only), `both` (First and last) | `auto` | always | Totals | diff --git a/docs/test_plan.md b/docs/test_plan.md index 122b00b2..71dc9b46 100644 --- a/docs/test_plan.md +++ b/docs/test_plan.md @@ -40,6 +40,7 @@ focused test suite passes. | **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 | +| **Highcharts backend** | highcharts-tests | Highcharts chart types (bar, line, area, scatter, pie, histogram, radar, heatmap, waterfall, sunburst, facets) | | **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 | @@ -386,5 +387,6 @@ Plus additional non-matrix test generators: - Temporal (7 generators) - ECharts backend (24 generators) - Chart.js backend (11 generators) +- Highcharts backend (13 generators) **43 named test generators** total across all categories. diff --git a/docs/tutorials/exploring-data.md b/docs/tutorials/exploring-data.md index 42b6dfcd..1274a3db 100644 --- a/docs/tutorials/exploring-data.md +++ b/docs/tutorials/exploring-data.md @@ -322,8 +322,8 @@ Workflow: 1. Browse [gallery](/gallery) by chart family. 2. Open an example in the editor. -3. Change one field name or semantic type and watch the compiler rebuild all - three backends. +3. Change one field name or semantic type and watch the compiler rebuild + the previews for every supported backend. --- diff --git a/package-lock.json b/package-lock.json index e77aae31..1cc4858c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1415,7 +1415,6 @@ "version": "3.0.6", "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@puppeteer/browsers/-/browsers-3.0.6.tgz", "integrity": "sha1-a3cuD8Ed6yVcijwUIZ40oWoqsj0=", - "dev": true, "license": "Apache-2.0", "dependencies": { "modern-tar": "^0.7.6", @@ -3828,7 +3827,6 @@ "version": "17.0.2", "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chromium-bidi/-/chromium-bidi-17.0.2.tgz", "integrity": "sha1-khpYbe7NDC2LkkLEwbc8OqOf93w=", - "dev": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", @@ -4566,7 +4564,6 @@ "version": "0.0.1653615", "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz", "integrity": "sha1-xgDgxhlhIVayQipm2Vi6GI2H2+g=", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/dunder-proto": { @@ -5682,6 +5679,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highcharts": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/highcharts/-/highcharts-13.1.1.tgz", + "integrity": "sha512-k/smhxCgaqHDsuy9ZCWs8FJNTPrbzc8BmMA4M/8f9JMwv9HgmNZjulyWwZ9q9ly6AzHpskL9mhlW8vgsSqnstg==", + "license": "https://www.highcharts.com/license", + "peerDependencies": { + "jspdf": "^4.1.0", + "svg2pdf.js": "^2.7.0" + }, + "peerDependenciesMeta": { + "jspdf": { + "optional": true + }, + "svg2pdf.js": { + "optional": true + } + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/highlight.js/-/highlight.js-10.7.3.tgz", @@ -6139,6 +6154,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6160,6 +6176,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6181,6 +6198,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6202,6 +6220,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6223,6 +6242,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6244,6 +6264,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6265,6 +6286,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6286,6 +6308,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6307,6 +6330,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6328,6 +6352,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6349,6 +6374,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7484,7 +7510,6 @@ "version": "3.0.1", "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mitt/-/mitt-3.0.1.tgz", "integrity": "sha1-6jbPDMMEA2Aa4HTI93twks2rNtE=", - "dev": true, "license": "MIT" }, "node_modules/mlly": { @@ -7504,7 +7529,6 @@ "version": "0.7.7", "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/modern-tar/-/modern-tar-0.7.7.tgz", "integrity": "sha1-ynHXlgNjAHaxBzOwdRzKsoS7we8=", - "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -7989,7 +8013,6 @@ "version": "25.4.0", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/puppeteer-core/-/puppeteer-core-25.4.0.tgz", "integrity": "sha1-L8ulOpq5TVXxluHkLb55S7rfh1k=", - "dev": true, "license": "Apache-2.0", "dependencies": { "@puppeteer/browsers": "3.0.6", @@ -9603,7 +9626,6 @@ "version": "2.12.2", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typed-query-selector/-/typed-query-selector-2.12.2.tgz", "integrity": "sha1-ZeJGKsawrs+uG/rBpPMCcHDbq6o=", - "dev": true, "license": "MIT" }, "node_modules/typescript": { @@ -10561,7 +10583,6 @@ "version": "0.4.2", "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", "integrity": "sha1-9Ru3HCYG6Q49VydgfHKLJdYXtYs=", - "dev": true, "license": "Apache-2.0" }, "node_modules/which": { @@ -10633,7 +10654,6 @@ "version": "8.21.1", "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ws/-/ws-8.21.1.tgz", "integrity": "sha1-BFZQzUsSB4CedUcUYiPDgUqa9YY=", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -10759,6 +10779,7 @@ "peerDependencies": { "chart.js": "^4.0.0", "echarts": "^5.0.0 || ^6.0.0", + "highcharts": "^11.0.0 || ^12.0.0 || ^13.0.0", "plotly.js": "^2.0.0 || ^3.0.0", "plotly.js-dist-min": "^2.0.0 || ^3.0.0", "vega": "^5.0.0 || ^6.0.0", @@ -10772,6 +10793,9 @@ "echarts": { "optional": true }, + "highcharts": { + "optional": true + }, "plotly.js": { "optional": true }, @@ -10801,6 +10825,8 @@ "chart.js": "^4.4.0", "echarts": "^6.0.0", "flint-chart": "^0.5.1", + "highcharts": "^13.1.1", + "puppeteer-core": "^25.4.0", "vega": "^6.0.0", "vega-interpreter": "^2.2.1", "vega-lite": "^6.0.0", @@ -10889,6 +10915,7 @@ "d3": "^7.9.0", "echarts": "^6.0.0", "flint-chart": "*", + "highcharts": "^13.1.1", "i18next": "^26.3.6", "katex": "^0.17.0", "lucide-react": "^1.27.0", diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index a8aefdc6..d68c32e3 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -60,6 +60,11 @@ "import": "./dist/chartjs/index.js", "require": "./dist/chartjs/index.cjs" }, + "./highcharts": { + "types": "./dist/highcharts/index.d.ts", + "import": "./dist/highcharts/index.js", + "require": "./dist/highcharts/index.cjs" + }, "./excel": { "types": "./dist/excel/index.d.ts", "import": "./dist/excel/index.js", @@ -132,6 +137,7 @@ }, "peerDependencies": { "chart.js": "^4.0.0", + "highcharts": "^11.0.0 || ^12.0.0 || ^13.0.0", "plotly.js": "^2.0.0 || ^3.0.0", "plotly.js-dist-min": "^2.0.0 || ^3.0.0", "echarts": "^5.0.0 || ^6.0.0", @@ -155,6 +161,9 @@ "chart.js": { "optional": true }, + "highcharts": { + "optional": true + }, "plotly.js": { "optional": true }, diff --git a/packages/flint-js/src/highcharts/assemble.ts b/packages/flint-js/src/highcharts/assemble.ts new file mode 100644 index 00000000..4b3decb2 --- /dev/null +++ b/packages/flint-js/src/highcharts/assemble.ts @@ -0,0 +1,414 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts chart assembly — Two-Stage Pipeline Coordinator. + * + * Reuses the **same core analysis pipeline** as Vega-Lite, ECharts, and + * Chart.js: + * Phase 0: resolveChannelSemantics → ChannelSemantics + * Step 0a: declareLayoutMode → LayoutDeclaration + * Step 0b: convertTemporalData → converted data + * Step 0c: filterOverflow → filtered data, nominalCounts + * Phase 1: computeLayout → LayoutResult + * + * Then diverges for Phase 2 (Highcharts-specific): + * template.instantiate → builds Highcharts option structure + * hcApplyLayoutToSpec → applies layout decisions to option + * + * Key structural differences from other backends: + * VL: { mark, encoding, data: {values}, width, height } + * EC: { xAxis, yAxis, series: [{type, data}], tooltip, legend, grid } + * CJS: { type, data: { labels, datasets[] }, options: { scales, plugins } } + * HC: { chart: {type}, xAxis: {categories}, yAxis: {title}, series: [{name, data}], + * tooltip, legend, colors, title } + * + * Highcharts has no native small-multiples support, so faceted inputs are + * assembled into a `_facet` config holding an array of per-panel options + * (same contract as the Chart.js backend). + * + * This module has NO React, Redux, or UI framework dependencies. + */ + +import { + ChartEncoding, + ChartTemplateDef, + ChartAssemblyInput, + AssembleOptions, + LayoutDeclaration, + InstantiateContext, +} from '../core/types'; +import type { ChartWarning } from '../core/types'; +import { applyEncodingOverrides } from '../core/encoding-overrides'; +import { applyAggregation } from '../core/aggregate'; +import { applyPivot, applyTransform, type PivotSurface, type TransformSurface } from '../core/pivot'; +import { hcGetTemplateDef } from './templates'; +import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; +import { computeZeroDecision } from '../core/semantic-types'; +import { filterOverflow } from '../core/filter-overflow'; +import { computeLayout, computeChannelBudgets, deriveStretchCaps, resolveBaseSize, resolveFacetColumnsOption } from '../core/compute-layout'; +import { decideColorMaps } from '../core/color-decisions'; +import { hcApplyLayoutToSpec, hcApplyTooltips } from './instantiate-spec'; +import { normalizeStaticSeries } from '../core/static-series'; +import { normalizeChartProperties } from '../core/normalize-properties'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +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']) { + const axes = Array.isArray(option[axisKey]) ? option[axisKey] : [option[axisKey]]; + for (const axis of axes) { + if (axis?.title?.text) axis.title.text = displayName(axis.title.text); + } + } + for (const series of option.series ?? []) { + if (series?.name) series.name = displayName(series.name); + } + if (option.title?.text) option.title.text = displayName(option.title.text); +} + +/** + * Assemble a Highcharts options object. + * + * ```ts + * const option = assembleHighcharts({ + * data: { values: myRows }, + * semantic_types: { weight: 'Quantity' }, + * chart_spec: { chartType: 'Bar Chart', encodings: { x: { field: 'category' }, y: { field: 'value' } } }, + * options: { addTooltips: true }, + * }); + * ``` + * + * @returns A Highcharts options object with optional `_warnings` and `_width`/`_height` hints + */ +export function assembleHighcharts(input: ChartAssemblyInput): any { + const chartType = input.chart_spec.chartType; + const semanticTypes = input.semantic_types ?? {}; + const sizeCeiling = input.chart_spec.canvasSize; + const baseSize = resolveBaseSize(input.chart_spec.baseSize, sizeCeiling); + const canvasSize = baseSize; + const options = input.options ?? {}; + let chartTemplate = hcGetTemplateDef(chartType) as ChartTemplateDef; + if (!chartTemplate) { + throw new Error(`Unknown Highcharts chart type: ${chartType}. Use hcAllTemplateDefs to see available types.`); + } + + const warnings: ChartWarning[] = []; + + const normalizedProps = normalizeChartProperties( + chartTemplate.properties, input.chart_spec.chartProperties, + ); + const chartProperties = normalizedProps.chartProperties; + warnings.push(...normalizedProps.warnings); + + // ═══════════════════════════════════════════════════════════════════════ + // PRE-PHASE: Static Series Normalization + // ═══════════════════════════════════════════════════════════════════════ + const rawData = input.data.values ?? []; + const normalized = normalizeStaticSeries( + input.chart_spec.encodings, rawData, semanticTypes, chartType, + ); + let data = normalized.data; + const staticSeries = normalized.staticSeries; + + const prelimConvertedData = convertTemporalData(data, semanticTypes); + const prelimSemantics = resolveChannelSemantics( + normalized.encodings, data, semanticTypes, prelimConvertedData, + ); + const typedRawEncodings: Record = {}; + for (const [ch, enc] of Object.entries(normalized.encodings)) { + typedRawEncodings[ch] = enc.type + ? enc + : { ...enc, type: prelimSemantics[ch]?.type }; + } + + const authoredTemplate = chartTemplate; + const transformed = applyTransform(chartTemplate, typedRawEncodings, data, chartProperties, hcGetTemplateDef); + if (transformed.chartType && transformed.chartType !== chartType) { + const swapped = hcGetTemplateDef(transformed.chartType) as ChartTemplateDef | undefined; + if (swapped) chartTemplate = swapped; + } + + const encodings = applyEncodingOverrides(chartTemplate, transformed.encodings, chartProperties); + + data = applyAggregation(encodings, data); + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 0: Resolve Semantics (shared with VL + EC + CJS) + // ═══════════════════════════════════════════════════════════════════════ + + const tplMark = chartTemplate.template?.mark; + const templateMarkType = typeof tplMark === 'string' ? tplMark : tplMark?.type; + + const convertedData = convertTemporalData(data, semanticTypes); + + const channelSemantics = resolveChannelSemantics( + encodings, data, semanticTypes, convertedData, + ); + + const effectiveMarkType = templateMarkType || 'point'; + for (const [channel, cs] of Object.entries(channelSemantics)) { + if ((channel === 'x' || channel === 'y') && cs.type === 'quantitative') { + const numericValues = data + .map(r => r[cs.field]) + .filter((v: any) => v != null && typeof v === 'number' && !isNaN(v)); + cs.zero = computeZeroDecision( + cs.semanticAnnotation.semanticType, channel, effectiveMarkType, numericValues, + ); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // STEP 0a: declareLayoutMode (shared hook) + // ═══════════════════════════════════════════════════════════════════════ + + const declaration: LayoutDeclaration = chartTemplate.declareLayoutMode + ? chartTemplate.declareLayoutMode(channelSemantics, data, chartProperties) + : {}; + + const effectiveOptions: AssembleOptions = { + defaultBandSize: 24, + bandStepFit: 1, + maxBandSize: 100, + baseLabelFontSize: 12, + baseTitleFontSize: 12, + ...options, + ...(declaration.paramOverrides || {}), + }; + + Object.assign(effectiveOptions, deriveStretchCaps(baseSize, sizeCeiling, effectiveOptions)); + effectiveOptions.bandStepFitCapacityX = sizeCeiling?.width ?? baseSize.width; + effectiveOptions.bandStepFitCapacityY = sizeCeiling?.height ?? baseSize.height; + effectiveOptions.facetColumns = resolveFacetColumnsOption(input.chart_spec.chartProperties); + + const { + addTooltips: addTooltipsOpt = true, + } = effectiveOptions; + + // ═══════════════════════════════════════════════════════════════════════ + // STEP 0b: filterOverflow (shared) + // ═══════════════════════════════════════════════════════════════════════ + + const allMarkTypes = new Set(); + if (templateMarkType) allMarkTypes.add(templateMarkType); + + const budgets = computeChannelBudgets( + channelSemantics, declaration, convertedData, canvasSize, effectiveOptions, + ); + const facetGridResult = budgets.facetGrid; + + const overflowResult = filterOverflow( + channelSemantics, declaration, encodings, convertedData, + budgets, allMarkTypes, + ); + + const values = overflowResult.filteredData; + warnings.push(...overflowResult.warnings); + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 1: Compute Layout (shared) + // ═══════════════════════════════════════════════════════════════════════ + + const layoutResult = computeLayout( + channelSemantics, + declaration, + values, + canvasSize, + effectiveOptions, + facetGridResult, + ); + + layoutResult.truncations = overflowResult.truncations; + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 2: Instantiate Highcharts Option (HC-specific) + // ═══════════════════════════════════════════════════════════════════════ + + const resolvedEncodings: Record = {}; + for (const [channel, encoding] of Object.entries(encodings)) { + const cs = channelSemantics[channel]; + if (cs) { + resolvedEncodings[channel] = { + field: cs.field, + type: cs.type, + aggregate: encoding.aggregate, + }; + } + } + + const instantiateContext: InstantiateContext = { + channelSemantics, + layout: layoutResult, + table: values, + fullTable: convertedData, + resolvedEncodings, + encodings, + chartProperties, + staticSeries, + canvasSize, + semanticTypes, + chartType, + assembleOptions: effectiveOptions, + colorDecisions: decideColorMaps({ + chartType, + encodings, + channelSemantics, + table: values, + background: 'light', + }), + }; + + const colField = channelSemantics.column?.field; + const rowField = channelSemantics.row?.field; + const hasFacet = !!(colField || rowField); + const hasAxes = chartTemplate.channels.includes('x') || chartTemplate.channels.includes('y'); + + let hcOption: any; + + if (hasFacet && hasAxes) { + // ── Faceted rendering — Highcharts has no native small multiples ── + // Emit a `_facet` config with a grid of per-panel options (same + // contract as the Chart.js backend). + const maxColsPerRow = facetGridResult?.columns ?? 1; + + let colValues: string[]; + if (colField && channelSemantics.column?.type === 'quantitative') { + const raw = values.map((r: any) => r[colField]).filter((v: any) => v != null && typeof v === 'number' && !isNaN(v)); + colValues = [...new Set(raw)].map(String); + } else { + colValues = colField ? [...new Set(values.map((r: any) => String(r[colField])))] : []; + } + + const wrapColumnOnly = !!colField && !rowField && maxColsPerRow < colValues.length; + const gridRows: Array> = []; + + if (wrapColumnOnly) { + for (let i = 0; i < colValues.length; i += maxColsPerRow) { + gridRows.push( + colValues.slice(i, i + maxColsPerRow).map((cv) => ({ colVal: cv, rowVal: '' })), + ); + } + } else { + const rowValues = rowField ? [...new Set(values.map((r: any) => String(r[rowField])))] : ['']; + for (const rv of rowValues) { + gridRows.push(colValues.map((cv) => ({ colVal: cv, rowVal: rv }))); + } + } + + const panelRows: any[][] = []; + for (const cells of gridRows) { + const rowPanels: any[] = []; + for (const { colVal, rowVal } of cells) { + const panelData = values.filter((r: any) => { + if (colField && String(r[colField]) !== colVal) return false; + if (rowField && String(r[rowField]) !== rowVal) return false; + return true; + }); + + const panelOption: any = structuredClone(chartTemplate.template); + const panelContext: InstantiateContext = { + ...instantiateContext, + table: panelData, + layout: layoutResult, + }; + chartTemplate.instantiate(panelOption, panelContext); + hcApplyLayoutToSpec(panelOption, panelContext, []); + if (addTooltipsOpt) hcApplyTooltips(panelOption); + if (chartTemplate.postProcess) chartTemplate.postProcess(panelOption, panelContext); + rowPanels.push({ + colVal, + rowVal, + option: panelOption, + }); + } + panelRows.push(rowPanels); + } + + hcOption = hcCombineFacetPanels( + panelRows, + !!colField, + !!rowField, + wrapColumnOnly, + ); + } else { + hcOption = structuredClone(chartTemplate.template); + chartTemplate.instantiate(hcOption, instantiateContext); + hcApplyLayoutToSpec(hcOption, instantiateContext, warnings); + if (addTooltipsOpt) hcApplyTooltips(hcOption); + if (chartTemplate.postProcess) chartTemplate.postProcess(hcOption, instantiateContext); + } + + // ═══════════════════════════════════════════════════════════════════════ + // RESULT + // ═══════════════════════════════════════════════════════════════════════ + + if (warnings.length > 0) { + hcOption._warnings = warnings; + } + if (overflowResult.viewports.length > 0) { + hcOption._viewports = overflowResult.viewports; + } + + hcOption._dataLength = values.length; + + if (transformed.surface) { + hcOption._transform = transformed.surface; + } + const legacyPivot = applyPivot(authoredTemplate, typedRawEncodings, data, chartProperties, hcGetTemplateDef); + if (legacyPivot.surface) { + hcOption._pivot = legacyPivot.surface; + } + + applyFieldDisplayNames(hcOption, input.field_display_names); + + return hcOption; +} + +/** Inspect the Highcharts legacy (composed) view transformation surface for an input. */ +export function getHighchartsPivot(input: ChartAssemblyInput): PivotSurface | undefined { + const spec = assembleHighcharts(input); + return spec && spec._pivot ? (spec._pivot as PivotSurface) : undefined; +} + +/** Inspect the Highcharts two-control transform surface for an input. */ +export function getHighchartsTransform(input: ChartAssemblyInput): TransformSurface | undefined { + const spec = assembleHighcharts(input); + return spec && spec._transform ? (spec._transform as TransformSurface) : undefined; +} + +/** + * Combine per-panel Highcharts options into a `_facet` config (same shape as + * the Chart.js backend's facet combiner). The site renders the grid of panel + * options with one Highcharts chart per panel. + */ +function hcCombineFacetPanels( + panelRows: any[][], + hasColHeader: boolean, + hasRowHeader: boolean, + colHeaderPerRow = false, +): any { + const rows = panelRows.length; + const cols = Math.max(1, ...panelRows.map(r => r.length)); + const ref = panelRows[0]?.[0]?.option; + const panelH = ref?._height || 300; + const panelW = ref?._width || 400; + const gap = 16; + const colHeaderH = hasColHeader ? 22 : 0; + const rowHeaderW = hasRowHeader ? 28 : 0; + + const headerBands = colHeaderPerRow ? rows : (hasColHeader ? 1 : 0); + + return { + _facet: true, + _facetPanels: panelRows, + _facetRows: rows, + _facetCols: cols, + _facetColHeaderPerRow: colHeaderPerRow, + _width: rowHeaderW + cols * panelW + (cols - 1) * gap, + _height: headerBands * colHeaderH + rows * panelH + (rows - 1) * gap, + }; +} diff --git a/packages/flint-js/src/highcharts/colormap.ts b/packages/flint-js/src/highcharts/colormap.ts new file mode 100644 index 00000000..edf2fd3b --- /dev/null +++ b/packages/flint-js/src/highcharts/colormap.ts @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts palette definitions + palette-selection logic. + * + * Mirrors echarts/colormap.ts: the abstract decision (schemeType / + * categoryCount / schemeId) comes from core/color-decisions; the choice of + * concrete colors is Highcharts-local. Highcharts consumes a top-level + * `colors` array on the chart options, so palettes here are plain hex lists. + */ + +import type { ColorDecision, ColorMapType } from '../core/color-decisions'; +import { DEFAULT_COLORS } from './templates/utils'; + +export type HighchartsPaletteId = 'cat10' | 'cat20' | 'viridis' | 'RdBu' | string; + +export interface HighchartsColorMapDef { + id: HighchartsPaletteId; + type: ColorMapType; + supportsDiscrete: boolean; + supportsContinuous: boolean; + background: 'light' | 'dark' | 'any'; + colorblindSafe?: boolean; + maxCategories?: number; + diverging?: boolean; + preferredMidpoint?: number; + colors: string[]; +} + +/** + * Highcharts default palette (v11+): the library's own categorical default, + * extended with a 20-slot variant for higher-cardinality legends. + */ +const HIGHCHARTS_COLOR_MAPS: HighchartsColorMapDef[] = [ + { + id: 'cat10', + type: 'categorical', + supportsDiscrete: true, + supportsContinuous: false, + background: 'any', + maxCategories: 10, + colorblindSafe: false, + colors: [ + '#2f7ed8', '#0d233a', '#8bbc21', '#910000', '#1aadce', + '#492970', '#f28f43', '#77a1e5', '#c42525', '#a6c96a', + ], + }, + { + id: 'cat20', + type: 'categorical', + supportsDiscrete: true, + supportsContinuous: false, + background: 'any', + maxCategories: 20, + colorblindSafe: false, + colors: [ + '#2f7ed8', '#0d233a', '#8bbc21', '#910000', '#1aadce', + '#492970', '#f28f43', '#77a1e5', '#c42525', '#a6c96a', + '#2f7ed8', '#0d233a', '#8bbc21', '#910000', '#1aadce', + '#492970', '#f28f43', '#77a1e5', '#c42525', '#a6c96a', + ], + }, + { + id: 'viridis', + type: 'sequential', + supportsDiscrete: true, + supportsContinuous: true, + background: 'any', + colorblindSafe: true, + colors: [ + '#440154', '#46327e', '#365c8d', '#277f8e', + '#1fa187', '#4ac16d', '#a0da39', '#fde725', + ], + }, + { + id: 'RdBu', + type: 'diverging', + supportsDiscrete: true, + supportsContinuous: true, + background: 'any', + colorblindSafe: false, + diverging: true, + preferredMidpoint: 0, + colors: [ + '#b2182b', '#d6604d', '#f4a582', '#fddbc7', + '#f7f7f7', + '#d1e5f0', '#92c5de', '#4393c3', '#2166ac', + ], + }, +]; + +function getMapById(id: HighchartsPaletteId | undefined): HighchartsColorMapDef | undefined { + if (!id) return undefined; + const key = String(id).toLowerCase(); + return HIGHCHARTS_COLOR_MAPS.find(m => m.id.toLowerCase() === key); +} + +export function getPaletteForScheme(id: HighchartsPaletteId): string[] | undefined { + const entry = getMapById(id); + return entry?.colors; +} + +/** + * Pick the concrete palette for a color decision (Highcharts-local). + * + * 1. An explicit schemeId wins when it maps to a known palette. + * 2. Otherwise choose by schemeType + categoryCount: + * - categorical → cat10 / cat20 by capacity + * - sequential → viridis + * - diverging → RdBu + * 3. Fall back to the Highcharts default palette. + */ +export function pickHighchartsPalette(decision: ColorDecision | undefined): string[] { + if (!decision) { + return DEFAULT_COLORS; + } + + const { schemeType, schemeId, categoryCount } = decision; + + if (schemeId) { + const fromId = getPaletteForScheme(schemeId); + if (fromId && fromId.length > 0) { + return fromId; + } + } + + const mapsOfType = HIGHCHARTS_COLOR_MAPS.filter(m => m.type === schemeType); + + if (schemeType === 'categorical') { + const k = categoryCount ?? 0; + const candidates = mapsOfType.filter(m => m.supportsDiscrete); + if (candidates.length) { + const byCapacity = candidates + .filter(m => m.maxCategories == null || m.maxCategories >= k) + .sort((a, b) => (a.maxCategories ?? Infinity) - (b.maxCategories ?? Infinity)); + const picked = byCapacity[0] ?? candidates[0]; + if (picked.colors.length) { + return picked.colors; + } + } + } else if (schemeType === 'sequential') { + const seq = mapsOfType.find(m => m.supportsContinuous) ?? getMapById('viridis'); + if (seq && seq.colors.length) { + return seq.colors; + } + } else if (schemeType === 'diverging') { + const divergingFirst = mapsOfType.find(m => m.diverging) ?? getMapById('RdBu'); + if (divergingFirst && divergingFirst.colors.length) { + return divergingFirst.colors; + } + } + + return DEFAULT_COLORS; +} diff --git a/packages/flint-js/src/highcharts/index.ts b/packages/flint-js/src/highcharts/index.ts new file mode 100644 index 00000000..f3482c69 --- /dev/null +++ b/packages/flint-js/src/highcharts/index.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts backend: assembleHighcharts, templates, spec instantiation. + */ + +export { assembleHighcharts, getHighchartsPivot, getHighchartsTransform } from './assemble'; +export { hcApplyLayoutToSpec, hcApplyTooltips } from './instantiate-spec'; +export { hcTemplateDefs, hcAllTemplateDefs, hcGetTemplateDef, hcGetTemplateChannels } from './templates'; +export { getPaletteForScheme, pickHighchartsPalette } from './colormap'; +export { createHighchartsInteractiveRenderer } from './interactive'; diff --git a/packages/flint-js/src/highcharts/instantiate-spec.ts b/packages/flint-js/src/highcharts/instantiate-spec.ts new file mode 100644 index 00000000..2a305637 --- /dev/null +++ b/packages/flint-js/src/highcharts/instantiate-spec.ts @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * ============================================================================= + * PHASE 2: INSTANTIATE SPEC — Highcharts backend + * ============================================================================= + * + * Translates semantic decisions (Phase 0) and layout dimensions (Phase 1) + * into Highcharts-specific option properties. + * + * Key differences from Vega-Lite/ECharts instantiation: + * - VL uses declarative encoding width: {step: N} — HC uses the chart + * container size (width/height on _width/_height + chart.width/height). + * - VL color schemes are strings — HC uses a top-level `colors` array. + * - VL handles zero-baseline via scale.zero — HC uses axis min: 0. + * - VL temporal formatting uses timeUnit — HC datetime axes auto-format. + * - VL label rotation is axis.labelAngle — HC uses xAxis.labels.rotation. + * - HC faceting has no native small multiples — the assembler emits a + * `_facet` config of per-panel options (see assemble.ts). + * + * HC dependency: **Yes — this is where Highcharts-specific syntax lives** + * ============================================================================= + */ + +import type { + InstantiateContext, + ChartWarning, +} from '../core/types'; +import { computePaddedDomain } from '../core/semantic-types'; +import { ColorDecisionResult } from '../core/color-decisions'; +import { pickHighchartsPalette } from './colormap'; +import { DEFAULT_COLORS } from './templates/utils'; + +/** + * Phase 2: Apply layout and semantic decisions to the Highcharts options object. + * + * Handles common Highcharts plumbing across all templates: + * - Canvas sizing (_width/_height + chart.width/height) + * - Zero-baseline on value axes + * - Axis titles, labels, fonts + * - Legend placement + * - Palette application (top-level `colors`) + * - Title/subtitle + */ +export function hcApplyLayoutToSpec( + option: any, + context: InstantiateContext, + _warnings: ChartWarning[], +): void { + const { channelSemantics, layout, canvasSize } = context; + + // ── Canvas dimensions ──────────────────────────────────────────────── + // Highcharts sizes to the chart container; the site renderer uses a + // wrapper div at the designed size. Only compute for axis-based charts. + const hasAxes = !!(option.xAxis || option.yAxis) && option.chart?.type !== 'pie'; + if (hasAxes && !option._width) { + const PADDING = 80; + const xIsDiscrete = layout.xNominalCount > 0 || layout.xContinuousAsDiscrete > 0; + const yIsDiscrete = layout.yNominalCount > 0 || layout.yContinuousAsDiscrete > 0; + + let plotWidth: number; + let plotHeight: number; + + if (xIsDiscrete && layout.xStepUnit !== 'group') { + const xItemCount = layout.xNominalCount || layout.xContinuousAsDiscrete || 0; + plotWidth = xItemCount > 0 ? layout.xStep * xItemCount : (layout.subplotWidth || canvasSize.width); + } else { + plotWidth = layout.subplotWidth || canvasSize.width; + } + + if (yIsDiscrete && layout.yStepUnit !== 'group') { + const yItemCount = layout.yNominalCount || layout.yContinuousAsDiscrete || 0; + plotHeight = yItemCount > 0 ? layout.yStep * yItemCount : (layout.subplotHeight || canvasSize.height); + } else { + plotHeight = layout.subplotHeight || canvasSize.height; + } + + const legendGutter = option.legend && option.legend.enabled !== false ? 96 : 0; + option._width = plotWidth + PADDING + legendGutter; + option._height = plotHeight + PADDING; + option.chart = option.chart || {}; + option.chart.width = option._width; + option.chart.height = option._height; + } + + // ── Zero-baseline and domain padding (value axes) ─────────────────────── + for (const axis of ['x', 'y'] as const) { + const axisObj = option[`${axis}Axis`]; + if (!axisObj) continue; + const cs = channelSemantics[axis]; + if (!cs?.zero) continue; + const decision = cs.zero; + // Highcharts category axes handle zero via the numeric y/x axis; only + // pin min: 0 when the zero decision says to include zero and the axis + // is numeric (not category/datetime). + const isNumericAxis = !axisObj.type || axisObj.type === 'linear' || axisObj.type === 'value' || axisObj.type === 'datetime' || axisObj.type === 'logarithmic'; + if (!isNumericAxis) continue; + if (decision.zero && axisObj.min === undefined) { + axisObj.min = 0; + } + if (!decision.zero && decision.domainPadFraction > 0 && cs.field) { + const numericValues = context.table + .map((r: any) => r[cs.field]) + .filter((v: any) => v != null && typeof v === 'number' && !isNaN(v)); + const padded = computePaddedDomain(numericValues, decision.domainPadFraction); + if (padded) { + if (axisObj.min === undefined) axisObj.min = padded[0]; + if (axisObj.max === undefined) axisObj.max = padded[1]; + } + } + } + + // ── Axis titles ───────────────────────────────────────────────────────── + if (option.xAxis && option.xAxis.title?.text) { + option.xAxis.title.style = { fontSize: layout.titleFontSize, ...(option.xAxis.title.style || {}) }; + } + if (option.yAxis && option.yAxis.title?.text) { + option.yAxis.title.style = { fontSize: layout.titleFontSize, ...(option.yAxis.title.style || {}) }; + } + + // ── Legend ────────────────────────────────────────────────────────────── + const hasLegend = option.legend && option.legend.enabled !== false; + if (hasLegend) { + option.legend = { + ...option.legend, + itemStyle: { + fontSize: layout.legendFontSize, + ...(option.legend.itemStyle || {}), + }, + }; + } + + // ── Title / subtitle ──────────────────────────────────────────────────── + if (context.chartProperties?.title != null) { + option.title = { text: String(context.chartProperties.title) }; + } + if (context.chartProperties?.subtitle != null) { + option.subtitle = { text: String(context.chartProperties.subtitle) }; + } + + // ── Color scheme ──────────────────────────────────────────────────────── + // Highcharts consumes a top-level `colors` array; the first series/series + // group without an explicit color takes colors[i]. + const decisions: ColorDecisionResult | undefined = context.colorDecisions; + const colorDecision = decisions ? (decisions.color ?? decisions.group) : undefined; + let effectivePalette: string[] | undefined; + if (decisions && colorDecision) { + effectivePalette = pickHighchartsPalette(colorDecision); + } else { + const colorPalette = context.resolvedEncodings?.color?.colorPalette + ?? context.resolvedEncodings?.group?.colorPalette; + if (colorPalette?.length) { + effectivePalette = colorPalette; + } + } + if (!effectivePalette || effectivePalette.length === 0) { + const cat10 = pickHighchartsPalette({ schemeType: 'categorical' } as any); + effectivePalette = cat10?.length ? cat10 : DEFAULT_COLORS; + } + if (effectivePalette && effectivePalette.length > 0) { + option.colors = [...effectivePalette]; + } + + // ── Credits (keep output clean; hosts can re-enable) ──────────────────── + if (!option.credits) { + option.credits = { enabled: false }; + } + + // ── Accessibility ─────────────────────────────────────────────────────── + // The core Highcharts package warns when the optional accessibility module + // is not loaded. Flint emits core-only specs, so disable it explicitly to + // keep the console clean; hosts that bundle the module can re-enable. + if (!option.accessibility) { + option.accessibility = { enabled: false }; + } + + // ── Bar sizing ────────────────────────────────────────────────────────── + // Highcharts auto-sizes columns within the plot area; padding between + // categories comes from plotOptions.column.pointPadding. + if (option.series && Array.isArray(option.series)) { + const barSeries = option.series.filter((s: any) => { + const t = s?.type ?? option.chart?.type; + return t === 'column' || t === 'bar'; + }); + if (barSeries.length > 0) { + const catAxis = option.xAxis?.categories ? 'x' : option.yAxis?.categories ? 'y' : null; + const step = catAxis === 'x' ? layout.xStep : catAxis === 'y' ? layout.yStep : 0; + if (step > 0 && layout.stepPadding > 0) { + const pointPadding = +(layout.stepPadding / 2).toFixed(3); + for (const s of barSeries) { + if (!s.pointPadding && s.pointPadding !== 0) { + s.pointPadding = pointPadding; + } + } + if (option.plotOptions) { + if (option.plotOptions.column && option.plotOptions.column.pointPadding == null) { + option.plotOptions.column.pointPadding = pointPadding; + } + if (option.plotOptions.bar && option.plotOptions.bar.pointPadding == null) { + option.plotOptions.bar.pointPadding = pointPadding; + } + } + } + } + } +} + +/** + * Apply the encoding-aware tooltip. Highcharts default tooltips already show + * series name + point value; this adds a shared crosshair for multi-series + * category charts when the encoding calls for it. + */ +export function hcApplyTooltips(option: any): void { + if (option.tooltip) return; + const seriesCount = Array.isArray(option.series) ? option.series.length : 0; + option.tooltip = seriesCount > 1 + ? { shared: true, crosshairs: true } + : { shared: false }; +} diff --git a/packages/flint-js/src/highcharts/interactive.ts b/packages/flint-js/src/highcharts/interactive.ts new file mode 100644 index 00000000..58345521 --- /dev/null +++ b/packages/flint-js/src/highcharts/interactive.ts @@ -0,0 +1,264 @@ +import Highcharts from 'highcharts'; +import { applyCategoryViewports } from '../core/filter-overflow'; +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import type { ChartUpdate, ChartUpdateResult } from '../interactive/language/updates'; +import type { SemanticElement } from '../core/interaction-contracts'; +import { assembleHighcharts } from './assemble'; + +function windowedInput( + input: ChartAssemblyInput, + viewports: CategoryViewport[], + starts: ViewportState, +): ChartAssemblyInput { + return { + ...input, + data: { + values: applyCategoryViewports(input.data.values ?? [], viewports, starts), + }, + }; +} + +interface ActiveSelection { + series: number; + point: number; +} + +const DIM_OPACITY = 0.15; + +export function createHighchartsInteractiveRenderer(): InteractiveRendererAdapter { + return { + async mount(container, input) { + const plannedOption = assembleHighcharts(input) as any; + const viewports = (plannedOption._viewports ?? []) as CategoryViewport[]; + const initialOption = viewports.length > 0 + ? assembleHighcharts(windowedInput(input, viewports, {})) as any + : plannedOption; + + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + wrapper.style.width = Number.isFinite(initialOption._width) ? `${initialOption._width}px` : '100%'; + wrapper.style.height = `${Number.isFinite(initialOption._height) ? initialOption._height : 320}px`; + wrapper.style.maxWidth = '100%'; + container.append(wrapper); + + let activeSelection: ActiveSelection | null = null; + let currentChart: Highcharts.Chart | null = null; + let destroyed = false; + let updateTimer: number | undefined; + let latestStarts: ViewportState = {}; + let lastOptionJson = ''; + + const applyVisualStates = (): void => { + if (!currentChart) return; + currentChart.series.forEach((series, seriesIndex) => { + const points = (series.points ?? []) as Array) => void }; + }>; + const seriesOpacity = activeSelection + ? (seriesIndex === activeSelection.series ? 1 : DIM_OPACITY) + : 1; + points.forEach((point) => { + if (point.graphic && typeof point.graphic.attr === 'function') { + point.graphic.attr({ opacity: seriesOpacity }); + } + }); + const parts = [ + (series as { graph?: { attr: (a: Record) => void } }).graph, + (series as { area?: { attr: (a: Record) => void } }).area, + ]; + parts.forEach((part) => { + if (part && typeof part.attr === 'function') { + part.attr({ opacity: seriesOpacity }); + } + }); + }); + }; + + const draw = (option: any): void => { + const optionJson = JSON.stringify(option); + if (optionJson === lastOptionJson && currentChart && !destroyed) return; + const isFirstDraw = !currentChart || lastOptionJson === ''; + lastOptionJson = optionJson; + if (isFirstDraw) { + wrapper.innerHTML = ''; + } + if (currentChart && !destroyed) { + try { + currentChart.destroy(); + } catch { + // ignore teardown errors + } + currentChart = null; + } + const el = document.createElement('div'); + el.style.width = '100%'; + el.style.height = '100%'; + if (!isFirstDraw) { + const oldContainers = wrapper.querySelectorAll('[data-highcharts-chart]'); + oldContainers.forEach(c => c.remove()); + } + wrapper.append(el); + + const plotOptions = option?.plotOptions ?? {}; + option = { + ...option, + plotOptions: { + ...plotOptions, + series: { + ...(plotOptions.series ?? {}), + cursor: 'pointer', + states: { + ...(plotOptions.series?.states ?? {}), + inactive: { enabled: false }, + }, + point: { + ...(plotOptions.series?.point ?? {}), + events: { + ...(plotOptions.series?.point?.events ?? {}), + click: function (this: Highcharts.Point) { + const point = this as Highcharts.Point; + const seriesIndex = point.series?.index ?? 0; + const pointIndex = point.index ?? -1; + + if (activeSelection && activeSelection.series === seriesIndex) { + activeSelection = null; + } else { + activeSelection = { series: seriesIndex, point: pointIndex }; + } + applyVisualStates(); + }, + mouseOver: function () { + applyVisualStates(); + }, + mouseOut: function () { + window.setTimeout(applyVisualStates, 0); + }, + }, + }, + }, + }, + }; + + currentChart = Highcharts.chart(el, { + ...option, + chart: { + ...(option?.chart ?? {}), + animation: false, + }, + }); + + applyVisualStates(); + }; + + draw(initialOption); + + const schedule = (): void => { + if (destroyed || updateTimer !== undefined) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + if (destroyed) return; + const option = assembleHighcharts(windowedInput(input, viewports, latestStarts)) as any; + draw(option); + }, 0); + }; + + return { + viewports, + getViewportGeometry(channel) { + const plot = wrapper.firstElementChild as HTMLElement | null; + if (!plot) return undefined; + const rect = plot.getBoundingClientRect(); + return channel === 'x' + ? { offset: rect.left, extent: rect.width } + : { offset: rect.top, extent: rect.height }; + }, + getInteractionContext() { + const selected: SemanticElement[] = []; + if (activeSelection && currentChart) { + const series = currentChart.series?.[activeSelection.series]; + const point = series?.points?.[activeSelection.point]; + if (point?.options && typeof point.options === 'object') { + const row = { ...(point.options as Record) }; + delete row._seriesIndex; + selected.push({ value: row, records: [row] }); + } + } + return { + chartType: input.chart_spec.chartType, + selected, + }; + }, + async applyUpdate(update: ChartUpdate): Promise { + const vpChange = extractViewportChange(update); + if (vpChange) { + const starts: ViewportState = {}; + if (vpChange.value.x) { + starts.x = vpChange.value.x.min; + } + if (vpChange.value.y) { + starts.y = vpChange.value.y.min; + } + latestStarts = starts; + schedule(); + return { status: 'applied', resolvedTargets: 1, unresolvedTargets: [], unsupportedOps: [] }; + } + return { + status: 'unsupported' as const, + resolvedTargets: 0, + unresolvedTargets: [], + unsupportedOps: [...new Set(update.ops.map((op) => op.op))], + }; + }, + async setUpdates(updates): Promise { + return updates.map(() => ({ + status: 'unsupported' as const, + resolvedTargets: 0, + unresolvedTargets: [], + unsupportedOps: [], + })); + }, + async clearUpdate() {}, + setViewports(starts) { + latestStarts = { ...starts }; + schedule(); + }, + resize(size) { + wrapper.style.width = `${size.width}px`; + wrapper.style.height = `${size.height}px`; + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + if (currentChart) { + try { + currentChart.destroy(); + } catch { + // ignore teardown errors + } + currentChart = null; + } + wrapper.remove(); + }, + }; + }, + }; +} + +interface ViewportChange { + axes: 'x' | 'y' | 'xy'; + value: { x?: { min: number; max: number }; y?: { min: number; max: number } }; +} + +function extractViewportChange(update: { ops: readonly unknown[] }): ViewportChange | null { + for (const op of update.ops as Array<{ op: string; axes?: 'x' | 'y' | 'xy'; value?: unknown }>) { + if (op.op === 'set-viewport' && op.value) { + return { + axes: op.axes ?? 'xy', + value: op.value as ViewportChange['value'], + }; + } + } + return null; +} diff --git a/packages/flint-js/src/highcharts/templates/bar.ts b/packages/flint-js/src/highcharts/templates/bar.ts new file mode 100644 index 00000000..b0c43d98 --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/bar.ts @@ -0,0 +1,495 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts Bar Chart templates: Bar, Stacked Bar, Grouped Bar. + * + * Highcharts mapping: + * VL/ECharts: encoding channels determine stacking/grouping implicitly + * HC: one series per color/group value; stacking via plotOptions.series.stacking, + * grouping is native when several series share a category axis. + */ + +import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; +import { + extractCategories, + groupBy, + detectAxes, + getCategoryOrder, + buildCategoryValues, + buildCategoryCounts, + categoryAxisLabelRotateDeg, +} from './utils'; +import { + detectBandedAxisFromSemantics, + detectBandedAxisForceDiscrete, +} from '../../core/axis-detection'; +import { planBandDodge, resolveDodge } from '../../core/band-dodge'; +import { makeCartesianPivot } from '../../core/pivot'; +import { makeSortAction } from '../../core/encoding-actions'; + +// ─── Bar Chart ────────────────────────────────────────────────────────────── + +export const hcBarChartDef: ChartTemplateDef = { + chart: 'Bar Chart', + template: { mark: 'bar', encoding: {} }, + channels: ['x', 'y', 'color', 'opacity', '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) return; + + const catCS = channelSemantics[categoryAxis]; + const valCS = channelSemantics[valueAxis]; + const colorField = channelSemantics.color?.field; + const bothDiscrete = + channelSemantics.x?.type === 'nominal' && channelSemantics.y?.type === 'nominal'; + + if (bothDiscrete) { + // x=Category, y=Group both nominal → heatmap: cell value = count per + // (Category, Group), rendered with the native Highcharts heatmap. + const categories = extractCategories(table, catField, getCategoryOrder(ctx, categoryAxis)); + const groups = extractCategories(table, valField, getCategoryOrder(ctx, valueAxis)); + + const data: [number, number, number][] = []; + let maxVal = 1; + for (let yi = 0; yi < groups.length; yi++) { + for (let xi = 0; xi < categories.length; xi++) { + const count = table.filter((r) => + String(r[catField] ?? '') === categories[xi] + && String(r[valField] ?? '') === groups[yi], + ).length; + data.push([xi, yi, count]); + if (count > maxVal) maxVal = count; + } + } + + const option: any = { + chart: { type: 'heatmap' }, + xAxis: { + categories, + title: { text: catField }, + labels: { rotation: categoryAxisLabelRotateDeg(categories, catCS?.type) }, + }, + yAxis: { + categories: groups, + title: { text: valField }, + reversed: true, + }, + colorAxis: { + min: 0, + max: maxVal, + minColor: '#f0f9ff', + maxColor: '#0369a1', + }, + legend: { + align: 'right', + layout: 'vertical', + verticalAlign: 'middle', + symbolHeight: 180, + }, + tooltip: { + pointFormat: '{point.x}, {point.y}: {point.value}', + }, + series: [{ + name: 'Count', + data, + borderWidth: 1, + borderColor: '#ffffff', + }], + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + return; + } + + // Color + quantitative value → one series per color value (side-by-side). + if (colorField && valCS?.type === 'quantitative') { + const categories = extractCategories(table, catField, getCategoryOrder(ctx, categoryAxis)); + const isHorizontal = categoryAxis === 'y'; + + const groups = groupBy(table, colorField); + const series = [...groups.keys()].map((name) => { + const rows = groups.get(name)!; + const data = valCS?.type === 'temporal' + ? buildCategoryCounts(rows, catField, categories) + : buildCategoryValues(rows, catField, valField, categories); + return { name, data }; + }); + + const option: any = { + chart: { type: isHorizontal ? 'bar' : 'column' }, + xAxis: isHorizontal + ? { title: { text: valField } } + : { + categories, + title: { text: catField }, + labels: { rotation: categoryAxisLabelRotateDeg(categories, catCS?.type) }, + }, + yAxis: isHorizontal + ? { categories, title: { text: catField } } + : { title: { text: valField } }, + legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }, + series, + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + return; + } + + let categories = extractCategories(table, catField, getCategoryOrder(ctx, categoryAxis)); + let values: (number | null)[]; + if (valCS?.type === 'temporal') { + values = buildCategoryCounts(table, catField, categories); + } else { + values = buildCategoryValues(table, catField, valField, categories); + } + if (catCS?.type === 'temporal') { + const pairs: [string, number | null][] = categories.map((c, i) => [c, values[i]]); + pairs.sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime()); + categories = pairs.map(p => p[0]); + values = pairs.map(p => p[1]); + } + + const isHorizontal = categoryAxis === 'y'; + const valueLabel = valCS?.type === 'temporal' ? 'Count' : valField; + + const option: any = { + chart: { type: isHorizontal ? 'bar' : 'column' }, + xAxis: isHorizontal + ? { title: { text: valueLabel } } + : { + categories, + title: { text: catField }, + labels: { rotation: categoryAxisLabelRotateDeg(categories, catCS?.type) }, + }, + yAxis: isHorizontal + ? { categories, title: { text: catField } } + : { title: { text: valueLabel } }, + legend: { enabled: false }, + series: [{ + name: valueLabel, + data: values, + }], + plotOptions: { + column: { + borderRadius: chartProperties?.cornerRadius ?? 0, + }, + bar: { + borderRadius: chartProperties?.cornerRadius ?? 0, + }, + }, + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { key: 'cornerRadius', label: 'Corners', type: 'continuous', min: 0, max: 15, step: 1, defaultValue: 0 }, + ] as ChartPropertyDef[], + encodingActions: [makeSortAction()] as EncodingActionDef[], + pivot: makeCartesianPivot({ + transpose: [['x', 'y']], + permute: [['x', 'y', 'color']], + shift: ['color', 'column', 'row'], + }), +}; + +// ─── Stacked Bar Chart ────────────────────────────────────────────────────── + +export const hcStackedBarChartDef: ChartTemplateDef = { + chart: 'Stacked Bar 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, + paramOverrides: { continuousMarkCrossSection: { x: 20, y: 20, seriesCountAxis: 'auto' } }, + }; + }, + instantiate: (spec, ctx) => { + const { channelSemantics, table } = ctx; + const { categoryAxis, valueAxis } = detectAxes(channelSemantics); + const colorField = channelSemantics.color?.field; + + const catField = channelSemantics[categoryAxis]?.field; + const valField = channelSemantics[valueAxis]?.field; + if (!catField || !valField) return; + + const catCS = channelSemantics[categoryAxis]; + const valCS = channelSemantics[valueAxis]; + let categories = extractCategories(table, catField, getCategoryOrder(ctx, categoryAxis)); + if (catCS?.type === 'temporal') { + categories = [...categories].sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + } + const isHorizontal = categoryAxis === 'y'; + const valueLabel = valCS?.type === 'temporal' ? 'Count' : valField; + + // All categorical → count per (x, color) with stacked columns. + if (colorField && channelSemantics.x?.type === 'nominal' && channelSemantics.y?.type === 'nominal') { + const categoriesX = extractCategories(table, channelSemantics.x!.field!, getCategoryOrder(ctx, 'x')); + const groups = groupBy(table, colorField); + + const series = [...groups.keys()].map((name) => ({ + name, + data: buildCategoryCounts(groups.get(name)!, channelSemantics.x!.field!, categoriesX), + })); + + const option: any = { + chart: { type: 'column' }, + xAxis: { + categories: categoriesX, + title: { text: channelSemantics.x!.field }, + labels: { rotation: categoryAxisLabelRotateDeg(categoriesX, channelSemantics.x?.type) }, + }, + yAxis: { title: { text: 'Count' }, min: 0, allowDecimals: false }, + legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }, + plotOptions: { series: { stacking: 'normal' } }, + series, + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + return; + } + + const option: any = { + chart: { type: isHorizontal ? 'bar' : 'column' }, + xAxis: isHorizontal + ? { title: { text: valueLabel } } + : { + categories, + title: { text: catField }, + labels: { rotation: categoryAxisLabelRotateDeg(categories, catCS?.type) }, + }, + yAxis: isHorizontal + ? { categories, title: { text: catField } } + : { title: { text: valueLabel } }, + legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }, + plotOptions: { series: { stacking: 'normal' } }, + series: [], + }; + + if (colorField) { + const groups = groupBy(table, colorField); + for (const [name, rows] of groups) { + const data = valCS?.type === 'temporal' + ? buildCategoryCounts(rows, catField, categories) + : buildCategoryValues(rows, catField, valField, categories); + option.series.push({ name, data }); + } + } else { + const data = valCS?.type === 'temporal' + ? buildCategoryCounts(table, catField, categories) + : buildCategoryValues(table, catField, valField, categories); + option.series.push({ name: valueLabel, data }); + } + + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { + key: 'stackMode', label: 'Stack', type: 'discrete', options: [ + { value: 'normal', label: 'Count' }, + { value: 'percent', label: 'Percent (100%)' }, + ], + defaultValue: 'normal', + check: (ctx) => ({ applicable: !!ctx.encodings.color?.field }), + } as ChartPropertyDef, + ], + encodingActions: [makeSortAction()] as EncodingActionDef[], + pivot: makeCartesianPivot({ + transpose: [['x', 'y']], + permute: [['x', 'y', 'color']], + shift: ['color', 'group', 'column', 'row'], + }), +}; + +// ─── Grouped Bar Chart ────────────────────────────────────────────────────── + +export const hcGroupedBarChartDef: ChartTemplateDef = { + chart: 'Grouped Bar Chart', + template: { mark: 'bar', encoding: {} }, + channels: ['x', 'y', 'group', 'color', 'column', 'row'], + markCognitiveChannel: 'length', + declareLayoutMode: (cs, table, chartProperties) => { + const result = detectBandedAxisForceDiscrete(cs, table, { preferAxis: 'x' }); + const axis = result?.axis || 'x'; + const decl: import('../../core/types').LayoutDeclaration = { + axisFlags: { [axis]: { banded: true } }, + resolvedTypes: result?.resolvedTypes, + }; + const groupField = cs.group?.field || cs.color?.field; + const axisField = cs[axis]?.field; + if (groupField && axisField) { + const plan = planBandDodge(table, axisField, groupField); + const { mode } = resolveDodge(plan, chartProperties?.dodge); + if (mode === 'local') decl.groupLaneCount = Math.max(1, plan.maxPerBand); + } + return decl; + }, + instantiate: (spec, ctx) => { + const { channelSemantics, table } = ctx; + + const groupField = channelSemantics.group?.field || channelSemantics.color?.field; + + // x=temporal, y=nominal, group=Segment → vertical grouped bars by Date. + if (channelSemantics.x?.type === 'temporal' + && (channelSemantics.y?.type === 'nominal' || channelSemantics.y?.type === 'ordinal') + && groupField + && channelSemantics.x.field) { + const xField = channelSemantics.x.field; + + const dateCategories = extractCategories(table, xField, getCategoryOrder(ctx, 'x')); + dateCategories.sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + + const segments = extractCategories(table, groupField, getCategoryOrder(ctx, 'group')); + const series = segments.map((name) => ({ + name, + data: dateCategories.map((d) => + table.filter((r) => + String(r[xField] ?? '') === d + && String(r[groupField] ?? '') === name, + ).length, + ), + })); + + const option: any = { + chart: { type: 'column' }, + xAxis: { + categories: dateCategories, + title: { text: xField }, + labels: { rotation: categoryAxisLabelRotateDeg(dateCategories, channelSemantics.x?.type) }, + }, + yAxis: { title: { text: 'Count' }, min: 0, allowDecimals: false }, + legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }, + series, + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + return; + } + + const { categoryAxis, valueAxis } = detectAxes(channelSemantics); + const catField = channelSemantics[categoryAxis]?.field; + const valField = channelSemantics[valueAxis]?.field; + const valType = channelSemantics[valueAxis]?.type; + + // Fallback: no numeric value axis, but a group channel → count per (x, group). + if ((!valField || valType === 'nominal' || valType === 'ordinal') && groupField && channelSemantics.x?.field) { + const xField = channelSemantics.x.field!; + let categories = extractCategories(table, xField, getCategoryOrder(ctx, 'x')); + if (channelSemantics.x?.type === 'temporal') { + categories = [...categories].sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + } + + const groups = groupBy(table, groupField); + const series = [...groups.keys()].map((name) => ({ + name, + data: buildCategoryCounts(groups.get(name)!, xField, categories), + })); + + const option: any = { + chart: { type: 'column' }, + xAxis: { + categories, + title: { text: xField }, + labels: { rotation: categoryAxisLabelRotateDeg(categories, channelSemantics.x?.type) }, + }, + yAxis: { title: { text: 'Count' }, min: 0, allowDecimals: false }, + legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }, + series, + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + return; + } + + if (!catField || !valField) return; + + const catCS = channelSemantics[categoryAxis]; + let categories = extractCategories(table, catField, getCategoryOrder(ctx, categoryAxis)); + if (catCS?.type === 'temporal') { + categories = [...categories].sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + } + const isHorizontal = categoryAxis === 'y'; + + const option: any = { + chart: { type: isHorizontal ? 'bar' : 'column' }, + xAxis: isHorizontal + ? { title: { text: valField } } + : { + categories, + title: { text: catField }, + labels: { rotation: categoryAxisLabelRotateDeg(categories, catCS?.type) }, + }, + yAxis: isHorizontal + ? { categories, title: { text: catField } } + : { title: { text: valField } }, + legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }, + series: [], + }; + + if (groupField) { + const groups = groupBy(table, groupField); + for (const [name, rows] of groups) { + const data = buildCategoryValues(rows, catField, valField, categories); + option.series.push({ name, data }); + } + } else { + const data = buildCategoryValues(table, catField, valField, categories); + option.series.push({ name: valField, data }); + } + + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { + key: 'dodge', label: 'Dodge', type: 'discrete', + options: [ + { value: 'auto', label: 'Auto' }, + { value: 'local', label: 'Local (compact)' }, + { value: 'global', label: 'Global (aligned)' }, + ], + defaultValue: 'auto', + check: (ctx) => { + const groupField = ctx.channelSemantics?.group?.field ?? ctx.encodings?.group?.field; + const axisField = ctx.channelSemantics?.x?.type === 'nominal' + ? ctx.channelSemantics?.x?.field + : ctx.channelSemantics?.y?.field; + const rows = ctx.data; + if (!groupField || !axisField || !rows) return { applicable: false }; + const plan = planBandDodge(rows, axisField, groupField); + return { applicable: plan.ambiguous, recommendedValue: plan.mode === 'none' ? 'auto' : plan.mode }; + }, + } as ChartPropertyDef, + ], + encodingActions: [makeSortAction()] as EncodingActionDef[], + pivot: makeCartesianPivot({ + transpose: [['x', 'y']], + permute: [['x', 'y', 'color']], + shift: ['color', 'group', 'column', 'row'], + }), +}; diff --git a/packages/flint-js/src/highcharts/templates/heatmap.ts b/packages/flint-js/src/highcharts/templates/heatmap.ts new file mode 100644 index 00000000..3d992c7d --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/heatmap.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts Heatmap template. + * + * Highcharts mapping: + * HC: chart.type = 'heatmap' with xAxis/yAxis categories and a colorAxis. + * Data is [xIndex, yIndex, value] triples. + * + * The Heatmap chart type is selected explicitly; the Bar template also falls + * back to a count-matrix heatmap when both x and y are discrete. + */ + +import { ChartTemplateDef } from '../../core/types'; +import { extractCategories, getCategoryOrder } from './utils'; + +export const hcHeatmapDef: ChartTemplateDef = { + chart: 'Heatmap', + template: { mark: 'point', encoding: {} }, + channels: ['x', 'y', 'color', 'column', 'row'], + markCognitiveChannel: 'area', + instantiate: (spec, ctx) => { + const { channelSemantics, table } = ctx; + const xField = channelSemantics.x?.field; + const yField = channelSemantics.y?.field; + const colorField = channelSemantics.color?.field; + + if (!xField || !yField) return; + + const categories = extractCategories(table, xField, getCategoryOrder(ctx, 'x')); + const groups = extractCategories(table, yField, getCategoryOrder(ctx, 'y')); + + // Aggregate the color measure per (x, y) cell; default to row count. + const cellValues = new Map(); + for (const row of table) { + const xi = categories.indexOf(String(row[xField] ?? '')); + const yi = groups.indexOf(String(row[yField] ?? '')); + if (xi < 0 || yi < 0) continue; + const key = `${xi},${yi}`; + const v = colorField ? Number(row[colorField]) : 1; + const val = Number.isFinite(v) ? v : 1; + if (!cellValues.has(key)) cellValues.set(key, { sum: 0, count: 0 }); + const entry = cellValues.get(key)!; + entry.sum += val; + entry.count += 1; + } + + const data: [number, number, number][] = []; + let minVal = Infinity; + let maxVal = -Infinity; + for (const [key, { sum, count }] of cellValues) { + const [xi, yi] = key.split(',').map(Number); + const v = count > 0 ? sum : 0; + data.push([xi, yi, v]); + if (v < minVal) minVal = v; + if (v > maxVal) maxVal = v; + } + if (minVal === Infinity) minVal = 0; + if (maxVal === -Infinity) maxVal = 1; + + const option: any = { + chart: { type: 'heatmap' }, + xAxis: { + categories, + title: { text: xField }, + }, + yAxis: { + categories: groups, + title: { text: yField }, + reversed: true, + }, + colorAxis: { + min: minVal, + max: Math.max(maxVal, minVal + 1), + minColor: '#f0f9ff', + maxColor: '#0369a1', + }, + legend: { + align: 'right', + layout: 'vertical', + verticalAlign: 'middle', + symbolHeight: 180, + }, + tooltip: { + pointFormat: '{point.x}, {point.y}: {point.value}', + }, + series: [{ + name: colorField ?? 'Count', + data, + borderWidth: 1, + borderColor: '#ffffff', + dataLabels: { + enabled: data.length <= 100, + color: '#000000', + }, + }], + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, +}; diff --git a/packages/flint-js/src/highcharts/templates/histogram.ts b/packages/flint-js/src/highcharts/templates/histogram.ts new file mode 100644 index 00000000..6b2e5c5d --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/histogram.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts Histogram template. + * + * Highcharts has no built-in bin transform — bins are computed client-side + * and rendered as a column chart with contiguous bars (pointPadding/grouping + * off via plotOptions.column.pointPadding = 0). + */ + +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { groupBy } from './utils'; + +function computeBins( + values: number[], + binCount: number, +): { categories: string[]; binWidth: number; minVal: number } { + const minVal = Math.min(...values); + const maxVal = Math.max(...values); + const range = maxVal - minVal; + const binWidth = range > 0 ? range / binCount : 1; + + const categories: string[] = []; + for (let i = 0; i < binCount; i++) { + const lo = (minVal + i * binWidth).toFixed(1); + const hi = (minVal + (i + 1) * binWidth).toFixed(1); + categories.push(`${lo}–${hi}`); + } + return { categories, binWidth, minVal }; +} + +function binIndex(v: number, minVal: number, binWidth: number, binCount: number): number { + let idx = Math.floor((v - minVal) / binWidth); + if (idx >= binCount) idx = binCount - 1; + if (idx < 0) idx = 0; + return idx; +} + +export const hcHistogramDef: ChartTemplateDef = { + chart: 'Histogram', + template: { mark: 'bar', encoding: {} }, + channels: ['x', 'color', 'column', 'row'], + markCognitiveChannel: 'length', + instantiate: (spec, ctx) => { + const { channelSemantics, table, chartProperties } = ctx; + const xField = channelSemantics.x?.field; + const colorField = channelSemantics.color?.field; + if (!xField) return; + + const values = table + .map(r => Number(r[xField])) + .filter(v => isFinite(v)); + if (values.length === 0) return; + + const binCount = chartProperties?.binCount || 10; + const { categories, binWidth, minVal } = computeBins(values, binCount); + + const option: any = { + chart: { type: 'column' }, + xAxis: { + categories, + title: { text: xField }, + labels: { rotation: categories.length > 10 ? 45 : 0 }, + }, + yAxis: { title: { text: 'Count' }, min: 0, allowDecimals: false }, + plotOptions: { + column: { + pointPadding: 0, + groupPadding: 0, + borderWidth: 0.5, + borderColor: '#ffffff', + }, + }, + series: [], + }; + + if (!colorField) { + const counts = new Array(binCount).fill(0); + for (const v of values) { + counts[binIndex(v, minVal, binWidth, binCount)]++; + } + option.legend = { enabled: false }; + option.series.push({ name: 'Count', data: counts }); + } else { + const groups = groupBy(table, colorField); + option.legend = { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }; + for (const [name, rows] of groups) { + const groupValues = rows + .map(r => Number(r[xField])) + .filter(v => isFinite(v)); + const counts = new Array(binCount).fill(0); + for (const v of groupValues) { + counts[binIndex(v, minVal, binWidth, binCount)]++; + } + option.series.push({ name, data: counts }); + } + } + + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { key: 'binCount', label: 'Bins', type: 'continuous', min: 3, max: 40, step: 1, defaultValue: 10 } as ChartPropertyDef, + ], +}; diff --git a/packages/flint-js/src/highcharts/templates/index.ts b/packages/flint-js/src/highcharts/templates/index.ts new file mode 100644 index 00000000..b34f272f --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/index.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts template registry. + * + * Mirrors the structure of echarts/templates/index.ts and + * chartjs/templates/index.ts but with Highcharts template definitions. + */ + +import { ChartTemplateDef } from '../../core/types'; +import { hcScatterPlotDef } from './scatter'; +import { hcBarChartDef, hcStackedBarChartDef, hcGroupedBarChartDef } from './bar'; +import { hcLineChartDef, hcAreaChartDef } from './line'; +import { hcPieChartDef, hcDoughnutChartDef } from './pie'; +import { hcHistogramDef } from './histogram'; +import { hcRadarChartDef } from './radar'; +import { hcHeatmapDef } from './heatmap'; +import { hcWaterfallChartDef } from './waterfall'; +import { hcSunburstChartDef } from './sunburst'; + +/** + * Highcharts chart template definitions, grouped by category. + */ +export const hcTemplateDefs: { [key: string]: ChartTemplateDef[] } = { + 'Scatter & Point': [hcScatterPlotDef], + 'Bar': [hcBarChartDef, hcGroupedBarChartDef, hcStackedBarChartDef, hcHistogramDef], + 'Line & Area': [hcLineChartDef, hcAreaChartDef], + 'Part-to-Whole': [hcPieChartDef, hcDoughnutChartDef, hcSunburstChartDef], + 'Polar': [hcRadarChartDef], + 'Statistical': [hcHeatmapDef], + 'Other': [hcWaterfallChartDef], +}; + +/** + * Flat list of all Highcharts chart template definitions. + */ +export const hcAllTemplateDefs: ChartTemplateDef[] = Object.values(hcTemplateDefs).flat(); + +/** + * Look up a Highcharts chart template definition by chart type name. + */ +export function hcGetTemplateDef(chartType: string): ChartTemplateDef | undefined { + return hcAllTemplateDefs.find(t => t.chart === chartType); +} + +/** + * Get the available channels for a Highcharts chart type. + */ +export function hcGetTemplateChannels(chartType: string): string[] { + return hcGetTemplateDef(chartType)?.channels || []; +} diff --git a/packages/flint-js/src/highcharts/templates/line.ts b/packages/flint-js/src/highcharts/templates/line.ts new file mode 100644 index 00000000..531b5db0 --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/line.ts @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts Line Chart and Area Chart templates. + * + * Highcharts mapping: + * VL: mark = "line" / "area" with x + y + color + * HC: chart.type = 'line' / 'area'; one series per color value. + * Temporal x → datetime axis with [timestamp, value] point pairs. + * Nominal x → category axis. Quantitative x → linear axis with pairs. + */ + +import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; +import { extractCategories, groupBy, getCategoryOrder, buildCategoryValues, categoryAxisLabelRotateDeg } from './utils'; +import { makeCartesianPivot } from '../../core/pivot'; +import { makeSortAction } from '../../core/encoding-actions'; + +const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; + +/** + * Build the x-axis option + per-series data shapes for a line/area chart. + * Returns { xAxis, buildData } where buildData(rows) produces series data + * aligned to the axis type. + */ +function buildLineXAxis( + ctx: any, + xCS: any, +): { xAxis: any; dataMode: 'category' | 'pairs' } { + const xField = xCS.field; + + if (isDiscrete(xCS.type)) { + const categories = extractCategories(ctx.table, xField, getCategoryOrder(ctx, 'x')); + return { + dataMode: 'category', + xAxis: { + categories, + title: { text: xField }, + labels: { rotation: categoryAxisLabelRotateDeg(categories, xCS.type) }, + }, + }; + } + + if (xCS.type === 'temporal') { + return { + dataMode: 'pairs', + xAxis: { + type: 'datetime', + title: { text: xField }, + }, + }; + } + + return { + dataMode: 'pairs', + xAxis: { + type: 'linear', + title: { text: xField }, + }, + }; +} + +/** Build [x, y] pairs for continuous axes, sorted by x. */ +function buildPairs(table: any[], xField: string, yField: string, temporal: boolean): [number, number][] { + const pairs: [number, number][] = []; + for (const row of table) { + const x = row[xField]; + const y = row[yField]; + if (x == null || y == null) continue; + const xv = temporal ? +new Date(x) : Number(x); + const yv = Number(y); + if (!Number.isFinite(xv) || !Number.isFinite(yv)) continue; + pairs.push([xv, yv]); + } + pairs.sort((a, b) => a[0] - b[0]); + return pairs; +} + +function buildLineSeries( + table: any[], + ctx: any, + xCS: any, + yField: string, + colorField: string | undefined, + sizeField: string | undefined, +): any[] { + const xField = xCS.field; + const { dataMode } = buildLineXAxis(ctx, xCS); + + const series: any[] = []; + + if (colorField) { + const groups = groupBy(table, colorField); + for (const [name, rows] of groups) { + series.push({ + name, + data: dataMode === 'category' + ? buildCategoryValues(rows, xField, yField, extractCategories(rows, xField, getCategoryOrder(ctx, 'x'))) + : buildPairs(rows, xField, yField, xCS.type === 'temporal'), + ...(sizeField ? { marker: { radius: 5 } } : {}), + }); + } + return series; + } + + series.push({ + name: yField, + data: dataMode === 'category' + ? buildCategoryValues(table, xField, yField, extractCategories(table, xField, getCategoryOrder(ctx, 'x'))) + : buildPairs(table, xField, yField, xCS.type === 'temporal'), + ...(sizeField ? { marker: { radius: 5 } } : {}), + }); + return series; +} + +// ─── Line Chart ───────────────────────────────────────────────────────────── + +export const hcLineChartDef: ChartTemplateDef = { + chart: 'Line Chart', + template: { mark: 'line', encoding: {} }, + channels: ['x', 'y', 'color', 'opacity', 'column', 'row'], + markCognitiveChannel: 'position', + declareLayoutMode: () => ({ + paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' } }, + }), + instantiate: (spec, ctx) => { + const { channelSemantics, table, chartProperties } = ctx; + const xCS = channelSemantics.x; + const yCS = channelSemantics.y; + const colorField = channelSemantics.color?.field; + const sizeField = channelSemantics.size?.field; + + if (!xCS?.field || !yCS?.field) return; + + const { xAxis } = buildLineXAxis(ctx, xCS); + const series = buildLineSeries(table, ctx, xCS, yCS.field, colorField, sizeField); + + const option: any = { + chart: { type: 'line' }, + xAxis, + yAxis: { title: { text: yCS.field } }, + legend: colorField + ? { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' } + : { enabled: false }, + plotOptions: { + line: { + marker: { enabled: !!sizeField }, + }, + }, + series, + }; + if (chartProperties?.interpolate === 'monotone') { + option.chart.type = 'spline'; + } + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { + key: 'interpolate', label: 'Curve', type: 'discrete', + options: [ + { value: 'linear', label: 'Straight' }, + { value: 'monotone', label: 'Smooth (spline)' }, + ], + defaultValue: 'linear', + } as ChartPropertyDef, + ], + encodingActions: [makeSortAction()] as EncodingActionDef[], + pivot: makeCartesianPivot({ + transpose: [['x', 'y']], + permute: [['x', 'y', 'color']], + shift: ['color', 'column', 'row'], + }), +}; + +// ─── Area Chart ───────────────────────────────────────────────────────────── + +export const hcAreaChartDef: ChartTemplateDef = { + chart: 'Area Chart', + template: { mark: 'area', encoding: {} }, + channels: ['x', 'y', 'color', 'opacity', 'column', 'row'], + markCognitiveChannel: 'length', + declareLayoutMode: () => ({ + paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' } }, + }), + instantiate: (spec, ctx) => { + const { channelSemantics, table } = ctx; + const xCS = channelSemantics.x; + const yCS = channelSemantics.y; + const colorField = channelSemantics.color?.field; + + if (!xCS?.field || !yCS?.field) return; + + const { xAxis } = buildLineXAxis(ctx, xCS); + const series = buildLineSeries(table, ctx, xCS, yCS.field, colorField, undefined); + + const option: any = { + chart: { type: 'area' }, + xAxis, + yAxis: { title: { text: yCS.field } }, + legend: colorField + ? { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' } + : { enabled: false }, + plotOptions: { + area: { + marker: { enabled: false }, + lineWidth: 1, + states: { hover: { lineWidth: 1 } }, + }, + }, + series, + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { + key: 'interpolate', label: 'Curve', type: 'discrete', + options: [ + { value: 'linear', label: 'Straight' }, + { value: 'monotone', label: 'Smooth (spline)' }, + ], + defaultValue: 'linear', + } as ChartPropertyDef, + ], + encodingActions: [makeSortAction()] as EncodingActionDef[], + pivot: makeCartesianPivot({ + transpose: [['x', 'y']], + permute: [['x', 'y', 'color']], + shift: ['color', 'column', 'row'], + }), +}; diff --git a/packages/flint-js/src/highcharts/templates/pie.ts b/packages/flint-js/src/highcharts/templates/pie.ts new file mode 100644 index 00000000..491000bf --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/pie.ts @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts Pie Chart and Doughnut Chart templates. + * + * Highcharts mapping: + * VL: mark = "arc" with theta + color + * HC: chart.type = 'pie'; series[0].data = [{ name, y, color }] per slice. + * Doughnut → plotOptions.pie.innerSize. + */ + +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { extractCategories } from './utils'; + +/** + * Reorder parallel label/value arrays in place by slice value. + */ +export function sortSlicesInPlace( + labels: string[], + values: number[], + sortSlices: unknown, +): void { + if (sortSlices !== 'descending' && sortSlices !== 'ascending') return; + const idx = values.map((_, i) => i); + idx.sort((a, b) => (sortSlices === 'descending' ? values[b] - values[a] : values[a] - values[b])); + const sortedLabels = idx.map((i) => labels[i]); + const sortedValues = idx.map((i) => values[i]); + labels.splice(0, labels.length, ...sortedLabels); + values.splice(0, values.length, ...sortedValues); +} + +const SORT_SLICES_PROPERTY: ChartPropertyDef = { + key: 'sortSlices', label: 'Sort slices', type: 'discrete', + options: [ + { value: 'none', label: 'Data order' }, + { value: 'descending', label: 'Largest first' }, + { value: 'ascending', label: 'Smallest first' }, + ], + defaultValue: 'none', +}; + +function buildPieData( + table: any[], + colorField: string | undefined, + sizeField: string | undefined, + sortSlices: unknown, +): { name: string; y: number }[] { + const slices: { name: string; y: number }[] = []; + + if (colorField && sizeField) { + const agg = new Map(); + for (const row of table) { + const cat = String(row[colorField] ?? ''); + const val = Number(row[sizeField]) || 0; + agg.set(cat, (agg.get(cat) ?? 0) + val); + } + const categories = extractCategories(table, colorField); + for (const cat of categories) { + slices.push({ name: cat, y: agg.get(cat) ?? 0 }); + } + } else if (colorField) { + const counts = new Map(); + for (const row of table) { + const cat = String(row[colorField] ?? ''); + counts.set(cat, (counts.get(cat) ?? 0) + 1); + } + const categories = extractCategories(table, colorField); + for (const cat of categories) { + slices.push({ name: cat, y: counts.get(cat) ?? 0 }); + } + } else if (sizeField) { + for (const row of table) { + const val = Number(row[sizeField]) || 0; + slices.push({ name: String(val), y: val }); + } + } + + sortSlicesInPlace( + slices.map(s => s.name), + slices.map(s => s.y), + sortSlices, + ); + return slices; +} + +function buildPieOption( + ctx: any, + chartType: 'Pie Chart' | 'Doughnut Chart', +): any { + const { channelSemantics, table, chartProperties } = ctx; + const colorField = channelSemantics.color?.field; + const sizeField = channelSemantics.size?.field; + + const innerRadius = chartProperties?.innerRadius ?? (chartType === 'Doughnut Chart' ? 50 : 0); + const isDoughnut = chartType === 'Doughnut Chart' || innerRadius > 0; + + const data = buildPieData(table, colorField, sizeField, chartProperties?.sortSlices); + + return { + chart: { type: 'pie' }, + legend: { layout: 'vertical', align: 'right', verticalAlign: 'middle' }, + plotOptions: { + pie: { + allowPointSelect: true, + cursor: 'pointer', + ...(isDoughnut ? { innerSize: `${innerRadius}%` } : {}), + dataLabels: { + enabled: true, + format: '{point.name}: {point.percentage:.1f} %', + }, + }, + }, + tooltip: { + pointFormat: '{series.name}: {point.percentage:.1f}%', + }, + series: [{ + name: sizeField ?? 'Count', + colorByPoint: true, + data, + }], + }; +} + +export const hcPieChartDef: ChartTemplateDef = { + chart: 'Pie Chart', + template: { mark: 'arc', encoding: {} }, + channels: ['size', 'color', 'column', 'row'], + markCognitiveChannel: 'area', + instantiate: (spec, ctx) => { + const option = buildPieOption(ctx, 'Pie Chart'); + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { key: 'innerRadius', label: 'Donut', type: 'continuous', min: 0, max: 60, step: 5, defaultValue: 0 } as ChartPropertyDef, + SORT_SLICES_PROPERTY, + ], +}; + +export const hcDoughnutChartDef: ChartTemplateDef = { + chart: 'Doughnut Chart', + template: { mark: 'arc', encoding: {} }, + channels: ['size', 'color', 'column', 'row'], + markCognitiveChannel: 'area', + instantiate: (spec, ctx) => { + const option = buildPieOption(ctx, 'Doughnut Chart'); + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { key: 'innerRadius', label: 'Inner radius', type: 'continuous', min: 20, max: 80, step: 5, defaultValue: 50 } as ChartPropertyDef, + SORT_SLICES_PROPERTY, + ], +}; diff --git a/packages/flint-js/src/highcharts/templates/radar.ts b/packages/flint-js/src/highcharts/templates/radar.ts new file mode 100644 index 00000000..425b7288 --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/radar.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts Radar Chart template. + * + * Highcharts mapping: + * HC: chart.polar = true; xAxis.categories = metric names; one polygon + * series per color group. Highcharts draws spokes and rings natively. + * + * Data model (long format): + * x (nominal): metric / axis name + * y (quantitative): value for that metric + * color (nominal): entity / group + */ + +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { extractCategories, groupBy } from './utils'; + +export const hcRadarChartDef: ChartTemplateDef = { + chart: 'Radar Chart', + template: { mark: 'point', encoding: {} }, + channels: ['x', 'y', 'color', 'column', 'row'], + markCognitiveChannel: 'position', + instantiate: (spec, ctx) => { + const { channelSemantics, table, chartProperties } = ctx; + const axisField = channelSemantics.x?.field; + const valueField = channelSemantics.y?.field; + const groupField = channelSemantics.color?.field; + + if (!axisField || !valueField) return; + + const metrics = extractCategories(table, axisField, channelSemantics.x?.ordinalSortOrder); + if (metrics.length < 2) return; + + const filled = chartProperties?.filled !== false; + + const series: any[] = []; + + if (groupField) { + const groups = groupBy(table, groupField); + for (const [name, rows] of groups) { + const metricVals = new Map(); + for (const row of rows) { + const m = String(row[axisField]); + const v = Number(row[valueField]) || 0; + if (!metricVals.has(m)) metricVals.set(m, { sum: 0, count: 0 }); + const entry = metricVals.get(m)!; + entry.sum += v; + entry.count += 1; + } + series.push({ + name, + type: 'line', + data: metrics.map(m => { + const agg = metricVals.get(m); + return agg && agg.count > 0 ? agg.sum / agg.count : 0; + }), + fillOpacity: filled ? 0.3 : 0, + }); + } + } else { + const metricVals = new Map(); + for (const row of table) { + const m = String(row[axisField]); + const v = Number(row[valueField]) || 0; + if (!metricVals.has(m)) metricVals.set(m, { sum: 0, count: 0 }); + const entry = metricVals.get(m)!; + entry.sum += v; + entry.count += 1; + } + series.push({ + name: valueField, + type: 'line', + data: metrics.map(m => { + const agg = metricVals.get(m); + return agg && agg.count > 0 ? agg.sum / agg.count : 0; + }), + fillOpacity: filled ? 0.3 : 0, + }); + } + + const option: any = { + chart: { polar: true, type: 'line' }, + xAxis: { + categories: metrics, + tickmarkPlacement: 'on', + lineWidth: 1, + }, + yAxis: { + gridLineInterpolation: 'polygon', + min: 0, + }, + legend: series.length > 1 + ? { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' } + : { enabled: false }, + tooltip: { shared: true }, + series, + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { + key: 'filled', label: 'Fill area', type: 'discrete', + options: [ + { value: true, label: 'Filled' }, + { value: false, label: 'Outline only' }, + ], + defaultValue: true, + } as ChartPropertyDef, + ], +}; diff --git a/packages/flint-js/src/highcharts/templates/scatter.ts b/packages/flint-js/src/highcharts/templates/scatter.ts new file mode 100644 index 00000000..f6d3a7cc --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/scatter.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts Scatter Plot template. + * + * Highcharts mapping: + * VL: mark = "point" with x + y (+ color/size) + * HC: chart.type = 'scatter'; xAxis/yAxis are linear; optional size channel + * → bubble series type with z values. + */ + +import { ChartTemplateDef, EncodingActionDef } from '../../core/types'; +import { groupBy } from './utils'; +import { makeCartesianPivot } from '../../core/pivot'; +import { makeSortAction } from '../../core/encoding-actions'; + +function buildScatterPairs(table: any[], xField: string, yField: string): [number, number][] { + const pairs: [number, number][] = []; + for (const row of table) { + const x = Number(row[xField]); + const y = Number(row[yField]); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + pairs.push([x, y]); + } + return pairs; +} + +function buildBubblePoints(table: any[], xField: string, yField: string, sizeField: string): { x: number; y: number; z: number }[] { + const points: { x: number; y: number; z: number }[] = []; + for (const row of table) { + const x = Number(row[xField]); + const y = Number(row[yField]); + const z = Number(row[sizeField]); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + points.push({ x, y, z: Number.isFinite(z) ? z : 1 }); + } + return points; +} + +export const hcScatterPlotDef: ChartTemplateDef = { + chart: 'Scatter Plot', + template: { mark: 'point', encoding: {} }, + channels: ['x', 'y', 'color', 'size', 'opacity', 'column', 'row'], + markCognitiveChannel: 'position', + declareLayoutMode: () => ({}), + instantiate: (spec, ctx) => { + const { channelSemantics, table } = ctx; + const xCS = channelSemantics.x; + const yCS = channelSemantics.y; + const colorField = channelSemantics.color?.field; + const sizeField = channelSemantics.size?.field; + + if (!xCS?.field || !yCS?.field) return; + + const useBubble = !!sizeField; + const series: any[] = []; + + if (colorField) { + const groups = groupBy(table, colorField); + for (const [name, rows] of groups) { + series.push({ + name, + type: useBubble ? 'bubble' : 'scatter', + data: useBubble + ? buildBubblePoints(rows, xCS.field, yCS.field, sizeField) + : buildScatterPairs(rows, xCS.field, yCS.field), + }); + } + } else { + series.push({ + name: yCS.field, + type: useBubble ? 'bubble' : 'scatter', + data: useBubble + ? buildBubblePoints(table, xCS.field, yCS.field, sizeField) + : buildScatterPairs(table, xCS.field, yCS.field), + }); + } + + const option: any = { + chart: { type: 'scatter' }, + xAxis: { title: { text: xCS.field } }, + yAxis: { title: { text: yCS.field } }, + legend: colorField + ? { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' } + : { enabled: false }, + tooltip: { + pointFormat: `● ${colorField ?? xCS.field}: {point.x}
${yCS.field}: {point.y}`, + }, + series, + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + encodingActions: [makeSortAction()] as EncodingActionDef[], + pivot: makeCartesianPivot({ + transpose: [['x', 'y']], + permute: [['x', 'y', 'color']], + shift: ['color', 'column', 'row'], + }), +}; diff --git a/packages/flint-js/src/highcharts/templates/sunburst.ts b/packages/flint-js/src/highcharts/templates/sunburst.ts new file mode 100644 index 00000000..1536f8cf --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/sunburst.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts Sunburst Chart template. + * + * Highcharts mapping: + * HC: series.type = 'sunburst' renders hierarchical data as concentric + * rings. Requires the host to load `highcharts/modules/sunburst.js`. + * + * Data model (long format): + * color (nominal): level-1 branch (e.g. Region) + * group/detail (nominal): level-2 leaf (e.g. Country) + * size (quantitative): leaf value; leaves without a size use row count. + * + * Points form a tree: { id, parent, name, value }. The root is implicit. + */ + +import { ChartTemplateDef } from '../../core/types'; +import { extractCategories } from './utils'; + +export const hcSunburstChartDef: ChartTemplateDef = { + chart: 'Sunburst Chart', + template: { mark: 'arc', encoding: {} }, + channels: ['color', 'group', 'detail', 'size', 'column', 'row'], + markCognitiveChannel: 'area', + instantiate: (spec, ctx) => { + const { channelSemantics, table } = ctx; + const colorField = channelSemantics.color?.field; + const groupField = channelSemantics.group?.field ?? channelSemantics.detail?.field; + const sizeField = channelSemantics.size?.field; + + if (!colorField) return; + + const branches = extractCategories(table, colorField, channelSemantics.color?.ordinalSortOrder); + + // Aggregate leaf values per (branch, leaf). + const leafValues = new Map(); + const leafNames = new Map(); + for (const row of table) { + const branch = String(row[colorField] ?? ''); + const leaf = groupField ? String(row[groupField] ?? '') : ''; + if (!branch || !leaf) continue; + const key = `${branch}·${leaf}`; + const v = sizeField ? Number(row[sizeField]) : 1; + leafValues.set(key, (leafValues.get(key) ?? 0) + (Number.isFinite(v) ? v : 1)); + if (!leafNames.has(key)) leafNames.set(key, leaf); + } + + const data: any[] = []; + for (const branch of branches) { + const branchTotal = [...leafValues.entries()] + .filter(([key]) => key.startsWith(`${branch}·`)) + .reduce((sum, [, v]) => sum + v, 0); + data.push({ + id: branch, + name: branch, + value: branchTotal > 0 ? branchTotal : undefined, + }); + for (const [key, value] of leafValues) { + if (!key.startsWith(`${branch}·`)) continue; + data.push({ + id: key, + parent: branch, + name: leafNames.get(key) ?? key.slice(branch.length + 1), + value, + }); + } + } + + const option: any = { + chart: { type: 'sunburst' }, + tooltip: { + pointFormat: '{point.name}: {point.value}', + }, + series: [{ + type: 'sunburst', + data, + allowDrillToNode: true, + cursor: 'pointer', + dataLabels: { + format: '{point.name}', + }, + levels: [ + { + level: 1, + colorByPoint: true, + dataLabels: { + style: { fontWeight: 'bold' }, + }, + }, + { + level: 2, + colorVariation: { + key: 'brightness', + to: 0.5, + }, + }, + ], + }], + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, +}; diff --git a/packages/flint-js/src/highcharts/templates/utils.ts b/packages/flint-js/src/highcharts/templates/utils.ts new file mode 100644 index 00000000..38ccdf99 --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/utils.ts @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Shared helper functions for Highcharts template hooks. + * Mirrors echarts/templates/utils.ts where the logic is target-agnostic. + * Pure logic — no UI dependencies. + */ + +import type { ChannelSemantics, InstantiateContext } from '../../core/types'; + +const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; + +/** + * Get canonical category order for a channel (from resolvedEncodings or channelSemantics). + * Honors the shared Sort encoding action (`encodings[channel].sortBy` = + * measure channel + `sortOrder`) by aggregating the measure per category. + */ +export function getCategoryOrder(ctx: InstantiateContext, channel: string): string[] | undefined { + const resolved = (ctx.resolvedEncodings as any)?.[channel]; + if (Array.isArray(resolved?.sortValues) && resolved.sortValues.length > 0) { + return resolved.sortValues.map(String); + } + + const ordinal = resolved?.ordinalSortOrder + ?? ctx.channelSemantics?.[channel]?.ordinalSortOrder; + + const enc = ctx.encodings?.[channel]; + const sortByChannel = enc?.sortBy; + if ( + typeof sortByChannel === 'string' + && (sortByChannel === 'x' || sortByChannel === 'y' || sortByChannel === 'color' + || sortByChannel === 'size' || sortByChannel === 'group') + ) { + const catField = ctx.channelSemantics?.[channel]?.field ?? enc?.field; + const sortByField = ctx.channelSemantics?.[sortByChannel]?.field + ?? ctx.encodings?.[sortByChannel]?.field; + if (catField && sortByField && ctx.table) { + return resolveCategoryOrder(ctx.table, catField, { + ordinalSortOrder: ordinal, + sortBy: sortByField, + sortOrder: enc?.sortOrder, + }); + } + } + + return ordinal; +} + +/** + * Resolve the display order of a categorical axis, honoring either a canonical + * `ordinalSortOrder` or a sort-by-measure request (`sortBy` + `sortOrder`). + */ +export function resolveCategoryOrder( + data: any[], + catField: string, + opts?: { ordinalSortOrder?: string[]; sortBy?: string; sortOrder?: 'ascending' | 'descending' }, +): string[] { + const base = extractCategories(data, catField, opts?.ordinalSortOrder); + if (!opts?.sortBy) return base; + const agg = new Map(); + for (const row of data) { + const cat = String(row[catField] ?? ''); + const v = Number(row[opts.sortBy]); + if (Number.isFinite(v)) agg.set(cat, (agg.get(cat) ?? 0) + v); + } + const dir = opts.sortOrder === 'ascending' ? 1 : -1; + return [...base].sort((a, b) => dir * ((agg.get(a) ?? 0) - (agg.get(b) ?? 0))); +} + +/** + * Extract unique category values from data for a given field, preserving order. + */ +export function extractCategories(data: any[], field: string, ordinalSortOrder?: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const row of data) { + const val = row[field]; + if (val != null) { + const key = String(val); + if (!seen.has(key)) { + seen.add(key); + result.push(key); + } + } + } + if (ordinalSortOrder && ordinalSortOrder.length > 0) { + const orderMap = new Map(ordinalSortOrder.map((v, i) => [v, i])); + result.sort((a, b) => { + const ia = orderMap.get(a); + const ib = orderMap.get(b); + if (ia !== undefined && ib !== undefined) return ia - ib; + if (ia !== undefined) return -1; + if (ib !== undefined) return 1; + return 0; + }); + } + return result; +} + +/** + * Group data by a categorical field. Returns a map: seriesName → rows[]. + */ +export function groupBy(data: any[], field: string): Map { + const groups = new Map(); + for (const row of data) { + const key = String(row[field] ?? ''); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(row); + } + return groups; +} + +/** + * Build values aligned to a category array, summing duplicates per category. + */ +export function buildCategoryValues( + rows: any[], + categoryField: string, + valueField: string, + categories: string[], +): (number | null)[] { + const map = new Map(); + for (const row of rows) { + const cat = String(row[categoryField] ?? ''); + const val = row[valueField]; + if (val != null && !isNaN(val)) { + map.set(cat, (map.get(cat) ?? 0) + Number(val)); + } + } + return categories.map(cat => map.get(cat) ?? null); +} + +/** Count rows per category (when value axis has no numeric field, e.g. temporal). */ +export function buildCategoryCounts( + rows: any[], + categoryField: string, + categories: string[], +): number[] { + const map = new Map(); + for (const row of rows) { + const cat = String(row[categoryField] ?? ''); + map.set(cat, (map.get(cat) ?? 0) + 1); + } + return categories.map(cat => map.get(cat) ?? 0); +} + +/** When both x and y are discrete: count per (category, group). Returns one row per group. */ +export function buildCategoryGroupCounts( + rows: any[], + categoryField: string, + groupField: string, + categories: string[], + groups: string[], +): number[][] { + return groups.map(group => + categories.map(cat => + rows.filter(r => String(r[categoryField] ?? '') === cat && String(r[groupField] ?? '') === group).length, + ), + ); +} + +/** Detect which axis is the category (banded) axis and which is the value axis. */ +export function detectAxes( + channelSemantics: Record, +): { categoryAxis: 'x' | 'y'; valueAxis: 'x' | 'y' } { + const xCS = channelSemantics.x; + const yCS = channelSemantics.y; + + if (xCS && isDiscrete(xCS.type)) { + return { categoryAxis: 'x', valueAxis: 'y' }; + } + if (yCS && isDiscrete(yCS.type)) { + return { categoryAxis: 'y', valueAxis: 'x' }; + } + if (xCS?.type === 'quantitative' && yCS?.type === 'temporal') { + return { categoryAxis: 'y', valueAxis: 'x' }; + } + if (xCS?.type === 'temporal' && yCS?.type === 'quantitative') { + return { categoryAxis: 'x', valueAxis: 'y' }; + } + return { categoryAxis: 'x', valueAxis: 'y' }; +} + +/** Highcharts default categorical palette (mirrors the library's built-in default). */ +export const DEFAULT_COLORS = [ + '#2f7ed8', '#0d233a', '#8bbc21', '#910000', '#1aadce', + '#492970', '#f28f43', '#77a1e5', '#c42525', '#a6c96a', +]; + +/** Few, short category labels → keep axis text horizontal (0°); else 90° to reduce overlap. */ +const HC_BAR_SHORT_CATEGORY_COUNT = 4; +const HC_BAR_SHORT_CATEGORY_LABEL_LEN = 8; + +export function categoryAxisLabelRotateDeg( + categories: string[], + channelType: string | undefined, +): number { + if (channelType === 'quantitative') return 0; + const labels = categories.map(c => String(c)); + if (labels.length === 0) return 0; + const maxLen = Math.max(...labels.map(s => s.length)); + if (labels.length <= HC_BAR_SHORT_CATEGORY_COUNT && maxLen <= HC_BAR_SHORT_CATEGORY_LABEL_LEN) { + return 0; + } + return 45; +} + +/** True when every label parses as numbers (dense quantitative banding). */ +export function areCategoriesNumeric(cats: string[]): boolean { + if (cats.length === 0) return true; + return cats.every((c) => { + const s = String(c).trim(); + if (s === '') return false; + const n = Number(s); + return !isNaN(n) && isFinite(n); + }); +} + +export { isDiscrete }; diff --git a/packages/flint-js/src/highcharts/templates/waterfall.ts b/packages/flint-js/src/highcharts/templates/waterfall.ts new file mode 100644 index 00000000..ae31c56f --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/waterfall.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts Waterfall Chart template. + * + * Highcharts mapping: + * HC: series.type = 'waterfall' natively renders floating cumulative bars. + * Points flagged isSum/isIntermediateSum are totals anchored at zero. + * + * Channels: + * - x category / step label (the banded axis) + * - y signed amount for the step + * - color optional explicit type column (start / increase / decrease / end) + * + * Mirrors chartjs/templates/waterfall.ts and echarts/templates/waterfall.ts. + */ + +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { resolveTotalsMode } from '../../chart-types/waterfall'; +import { extractCategories } from './utils'; + +const UP_COLOR = '#91cc71'; +const DOWN_COLOR = '#ee6666'; +const TOTAL_COLOR = '#2f7ed8'; + +export const hcWaterfallChartDef: ChartTemplateDef = { + chart: 'Waterfall Chart', + template: { mark: 'bar', encoding: {} }, + channels: ['x', 'y', 'color', 'column', 'row'], + markCognitiveChannel: 'length', + declareLayoutMode: () => ({ axisFlags: { x: { banded: true } } }), + instantiate: (spec, ctx) => { + const { channelSemantics, table, chartProperties } = ctx; + const catField = channelSemantics.x?.field; + const valField = channelSemantics.y?.field; + const colorField = channelSemantics.color?.field; + if (!catField || !valField || table.length === 0) return; + + const categories = extractCategories(table, catField, channelSemantics.x?.ordinalSortOrder); + const rows = categories + .map((cat) => table.find((r: any) => String(r[catField]) === cat)) + .filter(Boolean) as any[]; + const values = rows.map((r) => Number(r[valField]) || 0); + + const totalsMode = resolveTotalsMode(values, chartProperties?.totals); + const wantFirst = totalsMode === 'first' || totalsMode === 'both'; + const wantLast = totalsMode === 'last' || totalsMode === 'both'; + const types: string[] = colorField + ? rows.map((r) => String(r[colorField] ?? 'delta')) + : values.map(((_, i) => + wantFirst && i === 0 ? 'start' + : wantLast && i === values.length - 1 ? 'end' + : 'delta')); + + // Cumulative running total including the current row. + const cumulative: number[] = []; + let acc = 0; + for (const v of values) { acc += v; cumulative.push(acc); } + + // Highcharts computes the floating positions itself: each delta is the + // signed step value, and totals are isSum points anchored at 0. For an + // end total the row's value IS the total (it reconciles with the prior + // cumulative), so it is passed through unchanged. + const data = values.map((v, i) => { + const t = types[i]; + const isTotal = t === 'start' || t === 'end'; + const point: any = { + y: v, + color: isTotal ? TOTAL_COLOR : v >= 0 ? UP_COLOR : DOWN_COLOR, + }; + if (isTotal) point.isSum = true; + return point; + }); + + const option: any = { + chart: { type: 'column' }, + xAxis: { + categories, + title: { text: catField }, + }, + yAxis: { + title: { text: valField }, + min: Math.min(0, ...values), + }, + legend: { enabled: false }, + tooltip: { + pointFormat: '{point.low} → {point.high} ({point.change:+.2f})', + }, + plotOptions: { + waterfall: { + borderColor: '#333333', + borderWidth: 1, + dashStyle: 'Dot', + }, + }, + series: [{ + type: 'waterfall', + name: valField, + data, + }], + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { + key: 'totals', label: 'Totals', type: 'discrete', + options: [ + { value: 'auto', label: 'Auto (data-aware)' }, + { value: 'first', label: 'First only' }, + { value: 'last', label: 'Last only' }, + { value: 'both', label: 'First and last' }, + ], + defaultValue: 'auto', + } as ChartPropertyDef, + ], +}; diff --git a/packages/flint-js/src/index.ts b/packages/flint-js/src/index.ts index 1112f31d..63058f83 100644 --- a/packages/flint-js/src/index.ts +++ b/packages/flint-js/src/index.ts @@ -58,6 +58,9 @@ export * from './echarts'; // Chart.js backend: assembleChartjs, templates, spec instantiation export * from './chartjs'; + +// Highcharts backend: assembleHighcharts, templates, spec instantiation +export * from './highcharts'; export * from './plotly'; // Excel backend: assembleExcel + Excel chart spec types diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 390c6eb2..6257a9ed 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -254,5 +254,17 @@ export function buildInteractiveChart( }, { className, ariaLabel, chartId, updates, interactions, warnings }, ); + case 'highcharts': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createHighchartsInteractiveRenderer } = await import('../highcharts/interactive'); + return createHighchartsInteractiveRenderer().mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel, chartId, updates, interactions, warnings }, + ); } } \ No newline at end of file diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts index a879d65d..b0f9eb81 100644 --- a/packages/flint-js/src/interactive/types.ts +++ b/packages/flint-js/src/interactive/types.ts @@ -62,7 +62,7 @@ export interface InteractiveChartSurfaceOptions { warnings?: readonly ChartWarning[]; } -export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; +export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly' | 'highcharts'; export interface BuildInteractiveChartOptions extends InteractiveChartSurfaceOptions { backend: InteractiveBackend; diff --git a/packages/flint-js/src/test-data/highcharts-tests.ts b/packages/flint-js/src/test-data/highcharts-tests.ts new file mode 100644 index 00000000..010cbaf8 --- /dev/null +++ b/packages/flint-js/src/test-data/highcharts-tests.ts @@ -0,0 +1,626 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Highcharts backend gallery tests. + * + * Follows the same pattern as chartjs-tests.ts: each generator returns a + * TestCase[] covering normal, semantic, density, and edge cases for one + * chart family. Registered in TEST_GENERATORS (test-data/index.ts) so the + * gallery and the full-test-case matrix pick them up. + */ + +import { Type } from './df-types'; +import { TestCase, makeField, makeEncodingItem } from './types'; +import { seededRandom, genCategories, genMonths } from './generators'; + +// --------------------------------------------------------------------------- +// Test data generators +// --------------------------------------------------------------------------- + +function genBarData(seed: number) { + const rand = seededRandom(seed); + const products = ['Apples', 'Bananas', 'Cherries', 'Dates', 'Elderberries']; + return products.map(p => ({ + Product: p, + Sales: Math.round(100 + rand() * 900), + })); +} + +function genLineData(seed: number) { + const rand = seededRandom(seed); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + return months.map(m => ({ + Month: m, + Revenue: Math.round(1000 + rand() * 5000), + })); +} + +function genMultiSeriesLineData(seed: number) { + const rand = seededRandom(seed); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug']; + const series = ['ProductA', 'ProductB', 'ProductC']; + const data: any[] = []; + for (const m of months) { + for (const s of series) { + data.push({ + Month: m, + Sales: Math.round(500 + rand() * 2000), + Product: s, + }); + } + } + return data; +} + +function genStackedBarData(seed: number) { + const rand = seededRandom(seed); + const quarters = ['Q1', 'Q2', 'Q3', 'Q4']; + const regions = ['North', 'South', 'East', 'West']; + const data: any[] = []; + for (const q of quarters) { + for (const r of regions) { + data.push({ + Quarter: q, + Revenue: Math.round(50 + rand() * 400), + Region: r, + }); + } + } + return data; +} + +function genScatterData(n: number, seed: number) { + const rand = seededRandom(seed); + return Array.from({ length: n }, () => ({ + Weight: Math.round((40 + rand() * 60) * 10) / 10, + Height: Math.round((150 + rand() * 50) * 10) / 10, + })); +} + +function genScatterColorData(n: number, seed: number) { + const rand = seededRandom(seed); + const categories = ['Alpha', 'Beta', 'Gamma']; + return Array.from({ length: n }, (_, i) => ({ + X: Math.round(rand() * 100 * 10) / 10, + Y: Math.round(rand() * 100 * 10) / 10, + Group: categories[i % categories.length], + })); +} + +function genPieData(seed: number) { + const rand = seededRandom(seed); + const categories = ['Desktop', 'Mobile', 'Tablet', 'Other']; + return categories.map(c => ({ + Channel: c, + Sessions: Math.round(500 + rand() * 4500), + })); +} + +function genHistogramData(n: number, seed: number) { + const rand = seededRandom(seed); + return Array.from({ length: n }, () => ({ + Value: Math.round((rand() + rand() + rand() - 1.5) * 1000) / 10, + })); +} + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +export function genHighchartsBarTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genBarData(100); + tests.push({ + title: 'HC: Bar — Basic', + description: '5 products, single series.', + tags: ['highcharts', 'bar', 'simple'], + chartType: 'Bar Chart', + data, + fields: [makeField('Product'), makeField('Sales')], + metadata: { + Product: { type: Type.String, semanticType: 'Category', levels: ['Apples', 'Bananas', 'Cherries', 'Dates', 'Elderberries'] }, + Sales: { type: Type.Number, semanticType: 'Amount', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Product'), y: makeEncodingItem('Sales') }, + }); + } + + { + const rand = seededRandom(150); + const cities = genCategories('City', 20); + const data = cities.map(c => ({ + City: c, + Population: Math.round(10000 + rand() * 900000), + })); + tests.push({ + title: 'HC: Bar — 20 categories', + description: 'Many categories — tests layout and label rotation.', + tags: ['highcharts', 'bar', 'many-categories'], + chartType: 'Bar Chart', + data, + fields: [makeField('City'), makeField('Population')], + metadata: { + City: { type: Type.String, semanticType: 'Category', levels: cities }, + Population: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('City'), y: makeEncodingItem('Population') }, + }); + } + + { + const cats = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul']; + const vals = [120, -45, 80, -30, 60, -20, 95]; + const data = cats.map((c, i) => ({ Month: c, NetChange: vals[i] })); + tests.push({ + title: 'HC: Bar — Net Change (diverging)', + description: 'Positive and negative values render bars on both sides of the zero baseline.', + tags: ['highcharts', 'bar', 'diverging'], + chartType: 'Bar Chart', + data, + fields: [makeField('Month'), makeField('NetChange')], + metadata: { + Month: { type: Type.String, semanticType: 'Month', levels: cats }, + NetChange: { type: Type.Number, semanticType: 'Amount', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('NetChange') }, + }); + } + + { + const data = genBarData(101); + tests.push({ + title: 'HC: Bar — Horizontal', + description: 'Category on the y axis renders horizontal bars.', + tags: ['highcharts', 'bar', 'horizontal'], + chartType: 'Bar Chart', + data, + fields: [makeField('Product'), makeField('Sales')], + metadata: { + Product: { type: Type.String, semanticType: 'Category', levels: ['Apples', 'Bananas', 'Cherries', 'Dates', 'Elderberries'] }, + Sales: { type: Type.Number, semanticType: 'Amount', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Sales'), y: makeEncodingItem('Product') }, + }); + } + + return tests; +} + +export function genHighchartsStackedBarTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genStackedBarData(500); + tests.push({ + title: 'HC: Stacked Bar — Regions × Quarters', + description: 'Stacked bar chart with 4 quarters and 4 regions.', + tags: ['highcharts', 'stacked-bar', 'color'], + chartType: 'Stacked Bar Chart', + data, + fields: [makeField('Quarter'), makeField('Revenue'), makeField('Region')], + metadata: { + Quarter: { type: Type.String, semanticType: 'Category', levels: ['Q1', 'Q2', 'Q3', 'Q4'] }, + Revenue: { type: Type.Number, semanticType: 'Amount', levels: [] }, + Region: { type: Type.String, semanticType: 'Category', levels: ['North', 'South', 'East', 'West'] }, + }, + encodingMap: { x: makeEncodingItem('Quarter'), y: makeEncodingItem('Revenue'), color: makeEncodingItem('Region') }, + }); + } + + return tests; +} + +export function genHighchartsGroupedBarTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genStackedBarData(501); + tests.push({ + title: 'HC: Grouped Bar — Regions × Quarters', + description: 'Grouped bar chart with side-by-side series per region.', + tags: ['highcharts', 'grouped-bar', 'group'], + chartType: 'Grouped Bar Chart', + data, + fields: [makeField('Quarter'), makeField('Revenue'), makeField('Region')], + metadata: { + Quarter: { type: Type.String, semanticType: 'Category', levels: ['Q1', 'Q2', 'Q3', 'Q4'] }, + Revenue: { type: Type.Number, semanticType: 'Amount', levels: [] }, + Region: { type: Type.String, semanticType: 'Category', levels: ['North', 'South', 'East', 'West'] }, + }, + encodingMap: { x: makeEncodingItem('Quarter'), y: makeEncodingItem('Revenue'), group: makeEncodingItem('Region') }, + }); + } + + return tests; +} + +export function genHighchartsLineTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genLineData(200); + tests.push({ + title: 'HC: Line — Basic', + description: '6 months, single series.', + tags: ['highcharts', 'line', 'simple'], + chartType: 'Line Chart', + data, + fields: [makeField('Month'), makeField('Revenue')], + metadata: { + Month: { type: Type.String, semanticType: 'Month', levels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'] }, + Revenue: { type: Type.Number, semanticType: 'Amount', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('Revenue') }, + }); + } + + { + const data = genMultiSeriesLineData(201); + tests.push({ + title: 'HC: Line — Multi-series', + description: '3 products across 8 months.', + tags: ['highcharts', 'line', 'color'], + chartType: 'Line Chart', + data, + fields: [makeField('Month'), makeField('Sales'), makeField('Product')], + metadata: { + Month: { type: Type.String, semanticType: 'Month', levels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'] }, + Sales: { type: Type.Number, semanticType: 'Amount', levels: [] }, + Product: { type: Type.String, semanticType: 'Category', levels: ['ProductA', 'ProductB', 'ProductC'] }, + }, + encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('Sales'), color: makeEncodingItem('Product') }, + }); + } + + { + const months = genMonths(12); + const data = months.map((m, i) => ({ + Date: m, + Value: Math.round(100 + i * 5 + (i % 3) * 20), + })); + tests.push({ + title: 'HC: Line — Temporal (datetime axis)', + description: 'Date x-axis renders a native Highcharts datetime axis.', + tags: ['highcharts', 'line', 'temporal'], + chartType: 'Line Chart', + data, + fields: [makeField('Date'), makeField('Value')], + metadata: { + Date: { type: Type.String, semanticType: 'Date', levels: months }, + Value: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Date'), y: makeEncodingItem('Value') }, + }); + } + + return tests; +} + +export function genHighchartsAreaTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genLineData(300); + tests.push({ + title: 'HC: Area — Basic', + description: '6 months, filled area.', + tags: ['highcharts', 'area', 'simple'], + chartType: 'Area Chart', + data, + fields: [makeField('Month'), makeField('Revenue')], + metadata: { + Month: { type: Type.String, semanticType: 'Month', levels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'] }, + Revenue: { type: Type.Number, semanticType: 'Amount', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('Revenue') }, + }); + } + + { + const data = genMultiSeriesLineData(301); + tests.push({ + title: 'HC: Area — Multi-series', + description: '3 products across 8 months, filled areas.', + tags: ['highcharts', 'area', 'color'], + chartType: 'Area Chart', + data, + fields: [makeField('Month'), makeField('Sales'), makeField('Product')], + metadata: { + Month: { type: Type.String, semanticType: 'Month', levels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'] }, + Sales: { type: Type.Number, semanticType: 'Amount', levels: [] }, + Product: { type: Type.String, semanticType: 'Category', levels: ['ProductA', 'ProductB', 'ProductC'] }, + }, + encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('Sales'), color: makeEncodingItem('Product') }, + }); + } + + return tests; +} + +export function genHighchartsScatterTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genScatterData(40, 400); + tests.push({ + title: 'HC: Scatter — Basic', + description: '40 points, weight vs height.', + tags: ['highcharts', 'scatter', 'simple'], + chartType: 'Scatter Plot', + data, + fields: [makeField('Weight'), makeField('Height')], + metadata: { + Weight: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + Height: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Weight'), y: makeEncodingItem('Height') }, + }); + } + + { + const data = genScatterColorData(60, 401); + tests.push({ + title: 'HC: Scatter — Colored groups', + description: '60 points in 3 groups.', + tags: ['highcharts', 'scatter', 'color'], + chartType: 'Scatter Plot', + data, + fields: [makeField('X'), makeField('Y'), makeField('Group')], + metadata: { + X: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + Y: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + Group: { type: Type.String, semanticType: 'Category', levels: ['Alpha', 'Beta', 'Gamma'] }, + }, + encodingMap: { x: makeEncodingItem('X'), y: makeEncodingItem('Y'), color: makeEncodingItem('Group') }, + }); + } + + return tests; +} + +export function genHighchartsPieTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genPieData(500); + tests.push({ + title: 'HC: Pie — Channels', + description: '4 channels, one slice each.', + tags: ['highcharts', 'pie', 'simple'], + chartType: 'Pie Chart', + data, + fields: [makeField('Channel'), makeField('Sessions')], + metadata: { + Channel: { type: Type.String, semanticType: 'Category', levels: ['Desktop', 'Mobile', 'Tablet', 'Other'] }, + Sessions: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + }, + encodingMap: { color: makeEncodingItem('Channel'), size: makeEncodingItem('Sessions') }, + }); + } + + { + const data = genPieData(501); + tests.push({ + title: 'HC: Doughnut — Channels', + description: '4 channels with a doughnut hole.', + tags: ['highcharts', 'doughnut', 'simple'], + chartType: 'Doughnut Chart', + data, + fields: [makeField('Channel'), makeField('Sessions')], + metadata: { + Channel: { type: Type.String, semanticType: 'Category', levels: ['Desktop', 'Mobile', 'Tablet', 'Other'] }, + Sessions: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + }, + encodingMap: { color: makeEncodingItem('Channel'), size: makeEncodingItem('Sessions') }, + }); + } + + return tests; +} + +export function genHighchartsHistogramTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genHistogramData(200, 600); + tests.push({ + title: 'HC: Histogram — 200 values', + description: 'Bell-shaped distribution in 10 bins.', + tags: ['highcharts', 'histogram', 'simple'], + chartType: 'Histogram', + data, + fields: [makeField('Value')], + metadata: { + Value: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Value') }, + chartProperties: { binCount: 10 }, + }); + } + + return tests; +} + +export function genHighchartsRadarTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const metrics = ['Speed', 'Cost', 'Quality', 'Service']; + const entities = ['A', 'B']; + const data: any[] = []; + const rand = seededRandom(700); + for (const e of entities) { + for (const m of metrics) { + data.push({ + Metric: m, + Entity: e, + Value: Math.round(20 + rand() * 80), + }); + } + } + tests.push({ + title: 'HC: Radar — Two entities', + description: '4 metrics, 2 entity polygons.', + tags: ['highcharts', 'radar', 'color'], + chartType: 'Radar Chart', + data, + fields: [makeField('Metric'), makeField('Value'), makeField('Entity')], + metadata: { + Metric: { type: Type.String, semanticType: 'Category', levels: metrics }, + Value: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + Entity: { type: Type.String, semanticType: 'Category', levels: entities }, + }, + encodingMap: { x: makeEncodingItem('Metric'), y: makeEncodingItem('Value'), color: makeEncodingItem('Entity') }, + }); + } + + return tests; +} + +export function genHighchartsHeatmapTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = [ + { Category: 'A', Group: 'X' }, + { Category: 'A', Group: 'Y' }, + { Category: 'B', Group: 'X' }, + { Category: 'B', Group: 'Y' }, + { Category: 'C', Group: 'X' }, + ]; + tests.push({ + title: 'HC: Heatmap — Category × Group', + description: 'Count heatmap over two nominal axes.', + tags: ['highcharts', 'heatmap'], + chartType: 'Heatmap', + data, + fields: [makeField('Category'), makeField('Group')], + metadata: { + Category: { type: Type.String, semanticType: 'Category', levels: ['A', 'B', 'C'] }, + Group: { type: Type.String, semanticType: 'Category', levels: ['X', 'Y'] }, + }, + encodingMap: { x: makeEncodingItem('Category'), y: makeEncodingItem('Group') }, + }); + } + + { + const months = genMonths(6); + const products = ['P1', 'P2', 'P3']; + const rand = seededRandom(601); + const data: any[] = []; + for (const m of months) { + for (const p of products) { + data.push({ Month: m, Product: p, Sales: Math.round(rand() * 100) }); + } + } + tests.push({ + title: 'HC: Heatmap — Temporal × Category (value)', + description: 'Value heatmap with a continuous color axis.', + tags: ['highcharts', 'heatmap', 'temporal'], + chartType: 'Heatmap', + data, + fields: [makeField('Month'), makeField('Product'), makeField('Sales')], + metadata: { + Month: { type: Type.String, semanticType: 'Month', levels: months }, + Product: { type: Type.String, semanticType: 'Category', levels: products }, + Sales: { type: Type.Number, semanticType: 'Amount', levels: [] }, + }, + encodingMap: { + x: makeEncodingItem('Month'), + y: makeEncodingItem('Product'), + color: makeEncodingItem('Sales'), + }, + }); + } + + return tests; +} + +export function genHighchartsWaterfallTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = [ + { Step: 'Opening', Variance: 500 }, + { Step: 'Sales', Variance: 200 }, + { Step: 'Refunds', Variance: -45 }, + { Step: 'Closing', Variance: 655 }, + ]; + tests.push({ + title: 'HC: Waterfall — Budget bridge', + description: 'Opening to closing with positive and negative steps.', + tags: ['highcharts', 'waterfall'], + chartType: 'Waterfall Chart', + data, + fields: [makeField('Step'), makeField('Variance')], + metadata: { + Step: { type: Type.String, semanticType: 'Category', levels: ['Opening', 'Sales', 'Refunds', 'Closing'] }, + Variance: { type: Type.Number, semanticType: 'Amount', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Step'), y: makeEncodingItem('Variance') }, + }); + } + + return tests; +} + +export function genHighchartsSunburstTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = [ + { Region: 'Americas', Country: 'USA', Revenue: 20 }, + { Region: 'Americas', Country: 'Canada', Revenue: 15 }, + { Region: 'Europe', Country: 'France', Revenue: 30 }, + { Region: 'Europe', Country: 'Germany', Revenue: 25 }, + { Region: 'Asia', Country: 'Japan', Revenue: 40 }, + ]; + tests.push({ + title: 'HC: Sunburst — Region → Country', + description: 'Two-level hierarchy with leaf values.', + tags: ['highcharts', 'sunburst'], + chartType: 'Sunburst Chart', + data, + fields: [makeField('Region'), makeField('Country'), makeField('Revenue')], + metadata: { + Region: { type: Type.String, semanticType: 'Category', levels: ['Americas', 'Europe', 'Asia'] }, + Country: { type: Type.String, semanticType: 'Country', levels: ['USA', 'Canada', 'France', 'Germany', 'Japan'] }, + Revenue: { type: Type.Number, semanticType: 'Amount', levels: [] }, + }, + encodingMap: { + color: makeEncodingItem('Region'), + group: makeEncodingItem('Country'), + size: makeEncodingItem('Revenue'), + }, + }); + } + + return tests; +} + +export function genHighchartsFacetTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genStackedBarData(800); + tests.push({ + title: 'HC: Bar — Faceted by Region', + description: 'Column facet wraps into a grid of per-region bar charts.', + tags: ['highcharts', 'bar', 'facet'], + chartType: 'Bar Chart', + data, + fields: [makeField('Quarter'), makeField('Revenue'), makeField('Region')], + metadata: { + Quarter: { type: Type.String, semanticType: 'Category', levels: ['Q1', 'Q2', 'Q3', 'Q4'] }, + Revenue: { type: Type.Number, semanticType: 'Amount', levels: [] }, + Region: { type: Type.String, semanticType: 'Category', levels: ['North', 'South', 'East', 'West'] }, + }, + encodingMap: { x: makeEncodingItem('Quarter'), y: makeEncodingItem('Revenue'), column: makeEncodingItem('Region') }, + }); + } + + return tests; +} diff --git a/packages/flint-js/src/test-data/index.ts b/packages/flint-js/src/test-data/index.ts index 27761642..0b6c7857 100644 --- a/packages/flint-js/src/test-data/index.ts +++ b/packages/flint-js/src/test-data/index.ts @@ -41,6 +41,7 @@ export { genGasPressureTests } from './gas-pressure-tests'; export { genLineAreaStretchTests } from './line-area-stretch-tests'; export { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests'; export { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests'; +export { genHighchartsBarTests, genHighchartsStackedBarTests, genHighchartsGroupedBarTests, genHighchartsLineTests, genHighchartsAreaTests, genHighchartsScatterTests, genHighchartsPieTests, genHighchartsHistogramTests, genHighchartsRadarTests, genHighchartsHeatmapTests, genHighchartsWaterfallTests, genHighchartsSunburstTests, genHighchartsFacetTests } from './highcharts-tests'; export { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests'; export { genImageChartsTests } from './image-charts-tests'; export { genDiscreteAxisTests } from './discrete-axis-tests'; @@ -118,6 +119,7 @@ import { genDateYearTests, genDateMonthTests, genDateYearMonthTests, genDateDeca import { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests'; import { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests'; import { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests'; +import { genHighchartsBarTests, genHighchartsStackedBarTests, genHighchartsGroupedBarTests, genHighchartsLineTests, genHighchartsAreaTests, genHighchartsScatterTests, genHighchartsPieTests, genHighchartsHistogramTests, genHighchartsRadarTests, genHighchartsHeatmapTests, genHighchartsWaterfallTests, genHighchartsSunburstTests, genHighchartsFacetTests } from './highcharts-tests'; import { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests'; import { genImageChartsTests } from './image-charts-tests'; import { @@ -261,6 +263,19 @@ export const TEST_GENERATORS: Record TestCase[]> = { 'Chart.js: Stress Tests': genChartJsStressTests, 'Plotly: Core Templates': genPlotlyCoreTests, 'Plotly: Facets': genPlotlyFacetTests, + 'Highcharts: Bar': genHighchartsBarTests, + 'Highcharts: Stacked Bar': genHighchartsStackedBarTests, + 'Highcharts: Grouped Bar': genHighchartsGroupedBarTests, + 'Highcharts: Line': genHighchartsLineTests, + 'Highcharts: Area': genHighchartsAreaTests, + 'Highcharts: Scatter': genHighchartsScatterTests, + 'Highcharts: Pie': genHighchartsPieTests, + 'Highcharts: Histogram': genHighchartsHistogramTests, + 'Highcharts: Radar': genHighchartsRadarTests, + 'Highcharts: Heatmap': genHighchartsHeatmapTests, + 'Highcharts: Waterfall': genHighchartsWaterfallTests, + 'Highcharts: Sunburst': genHighchartsSunburstTests, + 'Highcharts: Facets': genHighchartsFacetTests, 'Image-Charts: Core Templates': genImageChartsTests, 'Gallery: Scatter': genGalleryRegionalSurveyScatterTests, 'Gallery: Line': genGalleryRegionalSurveyLineTests, diff --git a/packages/flint-js/src/validate/index.ts b/packages/flint-js/src/validate/index.ts index def98860..ab787424 100644 --- a/packages/flint-js/src/validate/index.ts +++ b/packages/flint-js/src/validate/index.ts @@ -35,15 +35,18 @@ import { assembleChartjs } from '../chartjs/assemble'; import { cjsGetTemplateDef } from '../chartjs/templates'; import { assemblePlotly } from '../plotly/assemble'; import { plGetTemplateDef } from '../plotly/templates'; +import { assembleHighcharts } from '../highcharts/assemble'; +import { hcGetTemplateDef } from '../highcharts/templates'; /** Backends whose inputs can be validated and assembled. */ -export type ValidationBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; +export type ValidationBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly' | 'highcharts'; export const VALIDATION_BACKENDS: readonly ValidationBackend[] = [ 'vegalite', 'echarts', 'chartjs', 'plotly', + 'highcharts', ]; /** Default cap on inline data rows. */ @@ -88,6 +91,7 @@ const ASSEMBLERS: Record any> echarts: assembleECharts, chartjs: assembleChartjs, plotly: assemblePlotly, + highcharts: assembleHighcharts, }; const TEMPLATE_LOOKUP: Record< @@ -98,6 +102,7 @@ const TEMPLATE_LOOKUP: Record< echarts: ecGetTemplateDef, chartjs: cjsGetTemplateDef, plotly: plGetTemplateDef, + highcharts: hcGetTemplateDef, }; /** @@ -314,21 +319,33 @@ export function validateInteractionSpec( assembled: unknown, ): ChartWarning[] { if (!input.interaction_spec) return []; - if (backend !== 'vegalite') { - return [{ - severity: 'info', - code: 'interactions_ignored', - message: `interaction_spec is ignored: backend "${backend}" does not run interactions.`, - }]; + if (backend === 'vegalite') { + try { + const resolved = resolveInteractionSpec(input.interaction_spec); + const { _interactionSemantics: plan } = assembled as { _interactionSemantics: Parameters[0] }; + return [...admitInteractions(plan, resolved.interactions).warnings]; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return [{ severity: 'error', code: 'invalid_interaction_spec', message }]; + } } - try { - const resolved = resolveInteractionSpec(input.interaction_spec); - const { _interactionSemantics: plan } = assembled as { _interactionSemantics: Parameters[0] }; - return [...admitInteractions(plan, resolved.interactions).warnings]; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return [{ severity: 'error', code: 'invalid_interaction_spec', message }]; + if (backend === 'highcharts') { + try { + const resolved = resolveInteractionSpec(input.interaction_spec); + if (!resolved.interactions.length) { + return [{ severity: 'info', code: 'interactions_ignored', message: 'interaction_spec declares no interactions.' }]; + } + return []; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return [{ severity: 'error', code: 'invalid_interaction_spec', message }]; + } } + return [{ + severity: 'info', + code: 'interactions_ignored', + message: `interaction_spec is ignored: backend "${backend}" does not run interactions.`, + }]; } /** diff --git a/packages/flint-js/tests/highcharts-backend.test.ts b/packages/flint-js/tests/highcharts-backend.test.ts new file mode 100644 index 00000000..67187d37 --- /dev/null +++ b/packages/flint-js/tests/highcharts-backend.test.ts @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleHighcharts, hcGetTemplateDef, hcAllTemplateDefs } from '../src'; +import { validateChart } from '../src/validate'; + +const SALES = [ + { region: 'East', revenue: 168 }, + { region: 'South', revenue: 167 }, + { region: 'North', revenue: 145 }, +]; + +const GROUPED = [ + { region: 'East', revenue: 168, year: '2024' }, + { region: 'South', revenue: 167, year: '2024' }, + { region: 'East', revenue: 120, year: '2025' }, + { region: 'South', revenue: 131, year: '2025' }, +]; + +const CARS = [ + { weight: 1.6, mpg: 32, origin: 'JP' }, + { weight: 2.1, mpg: 27, origin: 'US' }, + { weight: 1.9, mpg: 29, origin: 'EU' }, +]; + +const MONTHLY = [ + { month: '2025-01', value: 12 }, + { month: '2025-02', value: 18 }, + { month: '2025-03', value: 15 }, +]; + +function input( + chartType: string, + encodings: Record, + values: any[], + semanticTypes: Record, + chartProperties?: Record, +) { + return { + data: { values }, + semantic_types: semanticTypes, + chart_spec: { + chartType, + encodings, + baseSize: { width: 400, height: 300 }, + ...(chartProperties ? { chartProperties } : {}), + }, + } as any; +} + +/** Recursively assert a value contains no functions (pure JSON). */ +function assertNoFunctions(node: any, path = '$'): void { + if (typeof node === 'function') { + throw new Error(`function found at ${path}`); + } + if (node && typeof node === 'object') { + for (const [k, v] of Object.entries(node)) { + assertNoFunctions(v, `${path}.${k}`); + } + } +} + +describe('Highcharts backend', () => { + it('registers the acceptance templates', () => { + const charts = hcAllTemplateDefs.map(t => t.chart).sort(); + expect(charts).toEqual([...new Set(charts)]); + for (const acceptance of ['Area Chart', 'Bar Chart', 'Line Chart', 'Scatter Plot']) { + expect(charts).toContain(acceptance); + } + }); + + it('assembles a Bar Chart with categories and a single series', () => { + const option = assembleHighcharts(input( + 'Bar Chart', + { x: 'region', y: 'revenue' }, + SALES, + { region: 'Category', revenue: 'Quantity' }, + )) as any; + + expect(option.chart.type).toBe('column'); + // 'Category' semantic type → ordinal channel → alphabetical category order + expect(option.xAxis.categories).toEqual(['North', 'East', 'South']); + expect(option.series).toHaveLength(1); + expect(option.series[0].data).toEqual([145, 168, 167]); + expect(option.yAxis.title.text).toBe('revenue'); + expect(option._width).toBeGreaterThan(0); + expect(option._height).toBeGreaterThan(0); + assertNoFunctions(option); + }); + + it('assembles a horizontal Bar Chart when y is the category', () => { + const option = assembleHighcharts(input( + 'Bar Chart', + { x: 'revenue', y: 'region' }, + SALES, + { region: 'Category', revenue: 'Quantity' }, + )) as any; + + expect(option.chart.type).toBe('bar'); + expect(option.yAxis.categories).toEqual(['North', 'East', 'South']); + expect(option.xAxis.title.text).toBe('revenue'); + }); + + it('assembles a Stacked Bar Chart with one series per color', () => { + const option = assembleHighcharts(input( + 'Stacked Bar Chart', + { x: 'region', y: 'revenue', color: 'year' }, + GROUPED, + { region: 'Category', revenue: 'Quantity', year: 'Category' }, + )) as any; + + expect(option.chart.type).toBe('column'); + expect(option.plotOptions.series.stacking).toBe('normal'); + expect(option.series).toHaveLength(2); + expect(option.series.map((s: any) => s.name).sort()).toEqual(['2024', '2025']); + }); + + it('assembles a Grouped Bar Chart with side-by-side series', () => { + const option = assembleHighcharts(input( + 'Grouped Bar Chart', + { x: 'region', y: 'revenue', group: 'year' }, + GROUPED, + { region: 'Category', revenue: 'Quantity', year: 'Category' }, + )) as any; + + expect(option.chart.type).toBe('column'); + expect(option.series).toHaveLength(2); + expect(option.xAxis.categories).toEqual(['East', 'South']); + }); + + it('assembles a Line Chart with a category axis', () => { + const option = assembleHighcharts(input( + 'Line Chart', + { x: 'month', y: 'value' }, + MONTHLY, + { month: 'Category', value: 'Quantity' }, + )) as any; + + expect(option.chart.type).toBe('line'); + expect(option.xAxis.categories).toEqual(['2025-01', '2025-02', '2025-03']); + expect(option.series[0].data).toEqual([12, 18, 15]); + }); + + it('assembles a Line Chart with a datetime axis for temporal x', () => { + const option = assembleHighcharts(input( + 'Line Chart', + { x: 'month', y: 'value' }, + MONTHLY, + { month: 'Date', value: 'Quantity' }, + )) as any; + + expect(option.xAxis.type).toBe('datetime'); + expect(option.series[0].data.length).toBe(3); + // [timestamp, value] pairs + expect(option.series[0].data[0]).toHaveLength(2); + expect(typeof option.series[0].data[0][0]).toBe('number'); + }); + + it('assembles an Area Chart', () => { + const option = assembleHighcharts(input( + 'Area Chart', + { x: 'month', y: 'value' }, + MONTHLY, + { month: 'Category', value: 'Quantity' }, + )) as any; + + expect(option.chart.type).toBe('area'); + expect(option.series[0].data).toEqual([12, 18, 15]); + }); + + it('assembles a Scatter Plot with [x, y] pairs', () => { + const option = assembleHighcharts(input( + 'Scatter Plot', + { x: 'weight', y: 'mpg' }, + CARS, + { weight: 'Quantity', mpg: 'Quantity' }, + )) as any; + + expect(option.chart.type).toBe('scatter'); + expect(option.series[0].data).toEqual([ + [1.6, 32], + [2.1, 27], + [1.9, 29], + ]); + }); + + it('assembles a Scatter Plot with one series per color', () => { + const option = assembleHighcharts(input( + 'Scatter Plot', + { x: 'weight', y: 'mpg', color: 'origin' }, + CARS, + { weight: 'Quantity', mpg: 'Quantity', origin: 'Country' }, + )) as any; + + expect(option.series).toHaveLength(3); + expect(option.series.map((s: any) => s.name).sort()).toEqual(['EU', 'JP', 'US']); + }); + + it('assembles a Pie Chart with one slice per category', () => { + const option = assembleHighcharts(input( + 'Pie Chart', + { color: 'region', size: 'revenue' }, + SALES, + { region: 'Category', revenue: 'Quantity' }, + )) as any; + + expect(option.chart.type).toBe('pie'); + expect(option.series[0].data).toEqual([ + { name: 'East', y: 168 }, + { name: 'South', y: 167 }, + { name: 'North', y: 145 }, + ]); + }); + + it('assembles a Doughnut Chart with innerSize', () => { + const option = assembleHighcharts(input( + 'Doughnut Chart', + { color: 'region', size: 'revenue' }, + SALES, + { region: 'Category', revenue: 'Quantity' }, + )) as any; + + expect(option.chart.type).toBe('pie'); + expect(option.plotOptions.pie.innerSize).toBe('50%'); + }); + + it('assembles a Histogram with computed bins', () => { + const values = Array.from({ length: 50 }, (_, i) => ({ v: i })); + const option = assembleHighcharts(input( + 'Histogram', + { x: 'v' }, + values, + { v: 'Quantity' }, + { binCount: 10 }, + )) as any; + + expect(option.chart.type).toBe('column'); + expect(option.series[0].data).toHaveLength(10); + expect(option.series[0].data.reduce((a: number, b: number) => a + b, 0)).toBe(50); + }); + + it('assembles a Radar Chart with polar axes', () => { + const data = [ + { metric: 'Speed', entity: 'A', value: 80 }, + { metric: 'Cost', entity: 'A', value: 20 }, + { metric: 'Speed', entity: 'B', value: 100 }, + { metric: 'Cost', entity: 'B', value: 40 }, + ]; + const option = assembleHighcharts(input( + 'Radar Chart', + { x: 'metric', y: 'value', color: 'entity' }, + data, + { metric: 'Category', value: 'Quantity', entity: 'Category' }, + )) as any; + + expect(option.chart.polar).toBe(true); + expect(option.xAxis.categories).toEqual(['Speed', 'Cost']); + expect(option.series).toHaveLength(2); + expect(option.series[0].type).toBe('line'); + }); + + it('assembles a Heatmap with a colorAxis and [x, y, value] data', () => { + const data = [ + { Category: 'A', Group: 'X' }, + { Category: 'A', Group: 'Y' }, + { Category: 'B', Group: 'X' }, + ]; + const option = assembleHighcharts(input( + 'Heatmap', + { x: 'Category', y: 'Group' }, + data, + { Category: 'Category', Group: 'Category' }, + )) as any; + + expect(option.chart.type).toBe('heatmap'); + expect(option.xAxis.categories).toEqual(['A', 'B']); + expect(option.yAxis.categories).toEqual(['X', 'Y']); + expect(option.series[0].data).toContainEqual([0, 0, 1]); + expect(option.colorAxis).toBeDefined(); + }); + + it('falls back to a Heatmap when both bar axes are discrete', () => { + const data = [ + { Category: 'A', Group: 'X' }, + { Category: 'B', Group: 'Y' }, + ]; + const option = assembleHighcharts(input( + 'Bar Chart', + { x: 'Category', y: 'Group' }, + data, + { Category: 'Category', Group: 'Category' }, + )) as any; + + expect(option.chart.type).toBe('heatmap'); + }); + + it('assembles a Waterfall Chart with floating cumulative bars', () => { + const data = [ + { Step: 'Start', Value: 100 }, + { Step: 'Up', Value: 50 }, + { Step: 'Down', Value: -30 }, + ]; + const option = assembleHighcharts(input( + 'Waterfall Chart', + { x: 'Step', y: 'Value' }, + data, + { Step: 'Category', Value: 'Quantity' }, + )) as any; + + expect(option.series[0].type).toBe('waterfall'); + expect(option.xAxis.categories).toEqual(['Start', 'Up', 'Down']); + expect(option.series[0].data[0].isSum).toBe(true); + expect(option.series[0].data[0].y).toBe(100); + expect(option.series[0].data[1].y).toBe(50); + expect(option.series[0].data[2].y).toBe(-30); + }); + + it('assembles a Sunburst Chart with a hierarchical tree', () => { + const data = [ + { Region: 'Americas', Country: 'USA', Revenue: 20 }, + { Region: 'Americas', Country: 'Canada', Revenue: 15 }, + { Region: 'Europe', Country: 'France', Revenue: 30 }, + ]; + const option = assembleHighcharts(input( + 'Sunburst Chart', + { color: 'Region', group: 'Country', size: 'Revenue' }, + data, + { Region: 'Category', Country: 'Country', Revenue: 'Amount' }, + )) as any; + + expect(option.series[0].type).toBe('sunburst'); + const points = option.series[0].data; + expect(points.find((p: any) => p.id === 'Americas').value).toBe(35); + expect(points.find((p: any) => p.id === 'Americas·USA').parent).toBe('Americas'); + }); + + it('applies a categorical palette to the top-level colors array', () => { + const option = assembleHighcharts(input( + 'Bar Chart', + { x: 'region', y: 'revenue', color: 'year' }, + GROUPED, + { region: 'Category', revenue: 'Quantity', year: 'Category' }, + )) as any; + + expect(Array.isArray(option.colors)).toBe(true); + expect(option.colors.length).toBeGreaterThanOrEqual(2); + }); + + it('emits a _facet config for faceted inputs', () => { + const option = assembleHighcharts(input( + 'Bar Chart', + { x: 'region', y: 'revenue', column: 'year' }, + GROUPED, + { region: 'Category', revenue: 'Quantity', year: 'Category' }, + )) as any; + + expect(option._facet).toBe(true); + expect(option._facetPanels.length).toBeGreaterThanOrEqual(1); + expect(option._facetPanels[0][0].option.chart.type).toBe('column'); + }); + + it('validates through the shared validate module', () => { + const result = validateChart(input( + 'Bar Chart', + { x: 'region', y: 'revenue' }, + SALES, + { region: 'Category', revenue: 'Quantity' }, + ), 'highcharts'); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.computedSize?.width).toBeGreaterThan(0); + }); + + it('throws on an unknown chart type', () => { + expect(() => assembleHighcharts(input( + 'Nope Chart', + { x: 'region', y: 'revenue' }, + SALES, + { region: 'Category', revenue: 'Quantity' }, + ))).toThrow('Unknown Highcharts chart type'); + }); + + it('exposes template lookup helpers', () => { + expect(hcGetTemplateDef('Bar Chart')?.chart).toBe('Bar Chart'); + expect(hcGetTemplateDef('Nope')).toBeUndefined(); + expect(hcGetTemplateDef('Bar Chart')?.channels).toContain('x'); + }); +}); diff --git a/packages/flint-js/tests/smoke.test.ts b/packages/flint-js/tests/smoke.test.ts index c6aaad15..35f787a7 100644 --- a/packages/flint-js/tests/smoke.test.ts +++ b/packages/flint-js/tests/smoke.test.ts @@ -6,6 +6,7 @@ import { assembleVegaLite, assembleECharts, assembleChartjs, + assembleHighcharts, assemblePlotly, assembleExcel, assembleImageCharts, @@ -50,6 +51,34 @@ describe('public API smoke', () => { expect(config.type ?? config.data ?? config.options).toBeDefined(); }); + it('assembleHighcharts returns an options object', () => { + const option = assembleHighcharts(INPUT) as any; + expect(option).toBeDefined(); + expect(option.chart?.type ?? option.series ?? option.xAxis).toBeDefined(); + }); + + it('assembleHighcharts uses field display names for native axis and series titles', () => { + const input = { + data: { values: [ + { country: 'France', percentageOfCountries: 42 }, + { country: 'Japan', percentageOfCountries: 47 }, + ] }, + semantic_types: { country: 'Country', percentageOfCountries: 'Percent' }, + field_display_names: { + country: 'Country', + percentageOfCountries: 'Percentage of countries', + }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'country', y: 'percentageOfCountries' }, + }, + }; + + const highcharts = assembleHighcharts(input) as any; + expect(highcharts.xAxis.title.text).toBe('Country'); + expect(highcharts.yAxis.title.text).toBe('Percentage of countries'); + }); + it('uses field display names across native backend axis and series titles', () => { const input = { data: { values: [ diff --git a/packages/flint-js/tsup.config.ts b/packages/flint-js/tsup.config.ts index 96d76376..e129aee4 100644 --- a/packages/flint-js/tsup.config.ts +++ b/packages/flint-js/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ 'vegalite/index': 'src/vegalite/index.ts', 'echarts/index': 'src/echarts/index.ts', 'chartjs/index': 'src/chartjs/index.ts', + 'highcharts/index': 'src/highcharts/index.ts', 'plotly/index': 'src/plotly/index.ts', 'excel/index': 'src/excel/index.ts', 'image-charts/index': 'src/image-charts/index.ts', @@ -27,7 +28,7 @@ export default defineConfig({ treeshake: true, target: 'es2020', external: [ - 'vega', 'vega-lite', 'vega-tooltip', 'echarts', 'chart.js', 'plotly.js', 'plotly.js-dist-min', + 'vega', 'vega-lite', 'vega-tooltip', 'echarts', 'chart.js', 'highcharts', 'plotly.js', 'plotly.js-dist-min', '../vegalite/interactive', '../echarts/interactive', '../chartjs/interactive', '../plotly/interactive', ], }); diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index dc8b0761..57610d3f 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -10,7 +10,7 @@ description: "Use when: the user asks to make or render charts with flint-chart, Your output is the **spec**: the `chart_spec` and `semantic_types` of a `ChartAssemblyInput`. You reference data columns **by name**. The host passes the resulting input to `assembleVegaLite`, `assembleECharts`, -or `assembleChartjs` to get a backend spec. +`assembleChartjs`, or `assembleHighcharts` to get a backend spec. **You write the input spec, not the output spec.** And critically: @@ -67,16 +67,18 @@ npm install flint-chart npm install vega vega-lite vega-embed # browser Vega-Lite rendering npm install echarts # ECharts rendering npm install chart.js # Chart.js rendering +npm install highcharts # Highcharts rendering ``` Then compile with the requested backend: ```ts -import { assembleChartjs, assembleECharts, assembleVegaLite } from 'flint-chart'; +import { assembleChartjs, assembleECharts, assembleHighcharts, assembleVegaLite } from 'flint-chart'; const vegaLiteSpec = assembleVegaLite(input); const echartsOption = assembleECharts(input); const chartjsConfig = assembleChartjs(input); +const highchartsOption = assembleHighcharts(input); ``` Python support is planned for a later release. Until the PyPI package is @@ -123,7 +125,7 @@ Use the binding mode that matches the runtime. Do not mix them. 3. **Generated application or notebook code: bind runtime variables.** If the user asks you to add Flint to code, write normal data-loading code first and pass a real runtime value, e.g. `data: { values: rows }`, to - `assembleVegaLite`, `assembleECharts`, or `assembleChartjs`. This variable + `assembleVegaLite`, `assembleECharts`, `assembleChartjs`, or `assembleHighcharts`. This variable pattern is for generated code, not for MCP tool calls. For spec-only answers, return the `semantic_types` and `chart_spec` pieces and @@ -376,6 +378,8 @@ support a subset (verify if targeting a non-VL backend): - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, Lollipop, Bump, Combo, Line, Area, Range Area, Pie, Doughnut, Histogram, Radar, Rose, Slope, Connected Scatter. +- **Highcharts** supports: Scatter Plot, Bar, Grouped Bar, Stacked Bar, + Line, Area, Pie, Doughnut, Histogram, Radar, Heatmap, Waterfall, Sunburst. You do not need to call the library or inspect its source to author the input — pick from this table. diff --git a/packages/flint-mcp/package.json b/packages/flint-mcp/package.json index 06556d16..92d2a002 100644 --- a/packages/flint-mcp/package.json +++ b/packages/flint-mcp/package.json @@ -70,6 +70,8 @@ "chart.js": "^4.4.0", "echarts": "^6.0.0", "flint-chart": "^0.5.1", + "highcharts": "^13.1.1", + "puppeteer-core": "^25.4.0", "vega": "^6.0.0", "vega-interpreter": "^2.2.1", "vega-lite": "^6.0.0", diff --git a/packages/flint-mcp/src/cli.ts b/packages/flint-mcp/src/cli.ts index b773ba64..e6fb3cf5 100644 --- a/packages/flint-mcp/src/cli.ts +++ b/packages/flint-mcp/src/cli.ts @@ -9,8 +9,8 @@ import { SUPPORTED_BACKENDS, type SupportedBackend } from './tools/schemas.js'; const HELP = `flint-chart-mcp ${VERSION} -MCP server that compiles and renders Flint chart specs to Vega-Lite, ECharts, -or Chart.js artifacts (PNG/SVG), entirely in-process. +MCP server that compiles and renders Flint chart specs to Vega-Lite, +ECharts, Chart.js, or Highcharts artifacts (PNG/SVG), entirely in-process. Usage: flint-chart-mcp [options] Start the MCP server (stdio by default) diff --git a/packages/flint-mcp/src/render/highcharts.ts b/packages/flint-mcp/src/render/highcharts.ts new file mode 100644 index 00000000..329faa28 --- /dev/null +++ b/packages/flint-mcp/src/render/highcharts.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { svgToResult } from './svg.js'; +import type { RenderResult, RenderFormat } from './types.js'; + +const DEFAULT_W = 400; +const DEFAULT_H = 320; + +export interface HighchartsRenderArgs { + format: RenderFormat; + scale: number; + background: string; + width: number; + height: number; +} + +/** + * Render a Highcharts options object to an SVG string in a headless browser, + * then rasterize (for PNG) with resvg. Highcharts has no SSR mode, so this + * path drives a real Chrome via puppeteer-core and serializes the chart's + * SVG DOM node. + */ +export async function renderHighcharts( + option: any, + args: HighchartsRenderArgs, +): Promise> { + const puppeteer: any = await import('puppeteer-core'); + const width = args.width || DEFAULT_W; + const height = args.height || DEFAULT_H; + + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'], + }); + + try { + const page = await browser.newPage(); + await page.setViewport({ width, height, deviceScaleFactor: args.scale }); + + const html = ` + +
+ + +`; + + await page.setContent(html, { waitUntil: 'networkidle0' }); + const svg: string = await page.evaluate( + (opt: any) => (globalThis as any).__renderChart(opt), + { ...option, animation: false }, + ); + + return svgToResult(svg, { + backend: 'highcharts', + format: args.format, + scale: args.scale, + background: args.background, + width, + height, + }); + } finally { + await browser.close(); + } +} diff --git a/packages/flint-mcp/src/render/index.ts b/packages/flint-mcp/src/render/index.ts index 439d4e5b..eeee253e 100644 --- a/packages/flint-mcp/src/render/index.ts +++ b/packages/flint-mcp/src/render/index.ts @@ -9,6 +9,7 @@ import { import { renderVegaLite } from './vegalite.js'; import { renderECharts } from './echarts.js'; import { renderChartjs } from './chartjs.js'; +import { renderHighcharts } from './highcharts.js'; import type { RenderBackend, RenderFormat, @@ -42,6 +43,7 @@ export const RENDERABLE_BACKENDS: readonly RenderBackend[] = [ 'vegalite', 'echarts', 'chartjs', + 'highcharts', ]; /** @@ -109,6 +111,15 @@ export async function renderChart( height: h ?? 320, }); break; + case 'highcharts': + artifact = await renderHighcharts(spec, { + format, + scale, + background, + width: w ?? 400, + height: h ?? 320, + }); + break; } return { ...artifact, warnings }; diff --git a/packages/flint-mcp/src/render/types.ts b/packages/flint-mcp/src/render/types.ts index 35cfcbad..fd42c05f 100644 --- a/packages/flint-mcp/src/render/types.ts +++ b/packages/flint-mcp/src/render/types.ts @@ -4,7 +4,7 @@ import type { ChartWarning } from 'flint-chart'; /** Rendering backends that can produce an artifact. */ -export type RenderBackend = 'vegalite' | 'echarts' | 'chartjs'; +export type RenderBackend = 'vegalite' | 'echarts' | 'chartjs' | 'highcharts'; /** Output artifact format. */ export type RenderFormat = 'png' | 'svg'; diff --git a/packages/flint-mcp/src/tools/list.ts b/packages/flint-mcp/src/tools/list.ts index 47b92fa0..12fb4b68 100644 --- a/packages/flint-mcp/src/tools/list.ts +++ b/packages/flint-mcp/src/tools/list.ts @@ -5,6 +5,7 @@ import { vlAllTemplateDefs, ecAllTemplateDefs, cjsAllTemplateDefs, + hcAllTemplateDefs, listThemePresets, supportedInteractionPresets, THEME_PRESETS, @@ -17,6 +18,7 @@ const REGISTRY: Record = { vegalite: vlAllTemplateDefs, echarts: ecAllTemplateDefs, chartjs: cjsAllTemplateDefs, + highcharts: hcAllTemplateDefs, }; export interface ChartTypeInfo { diff --git a/packages/flint-mcp/src/tools/schemas.ts b/packages/flint-mcp/src/tools/schemas.ts index ca205ae2..45f18e23 100644 --- a/packages/flint-mcp/src/tools/schemas.ts +++ b/packages/flint-mcp/src/tools/schemas.ts @@ -4,8 +4,8 @@ import { z } from 'zod'; import { INTERACTION_PRESET_TYPES, type ChartAssemblyInput, type InteractionPresetType } from 'flint-chart'; -/** The three backends this server can compile, validate, and render. */ -export const SUPPORTED_BACKENDS = ['vegalite', 'echarts', 'chartjs'] as const; +/** The backends this server can compile, validate, and render. */ +export const SUPPORTED_BACKENDS = ['vegalite', 'echarts', 'chartjs', 'highcharts'] as const; export type SupportedBackend = (typeof SUPPORTED_BACKENDS)[number]; /** diff --git a/packages/flint-mcp/tests/server.test.ts b/packages/flint-mcp/tests/server.test.ts index 14f514b0..03b1fcd1 100644 --- a/packages/flint-mcp/tests/server.test.ts +++ b/packages/flint-mcp/tests/server.test.ts @@ -198,7 +198,7 @@ describe('MCP server', () => { const read = await client.readResource({ uri: 'flint://chart-types' }); const payload = JSON.parse(resourceText(read.contents[0])); expect(Array.isArray(payload)).toBe(true); - expect(payload.length).toBe(3); + expect(payload.length).toBe(4); }); it('exposes the bundled agent skill as a resource', async () => { diff --git a/scripts/gen-chart-reference.ts b/scripts/gen-chart-reference.ts index 132727d7..4bf6ba6c 100644 --- a/scripts/gen-chart-reference.ts +++ b/scripts/gen-chart-reference.ts @@ -23,6 +23,7 @@ import type { ExcelTemplateDef } from '../packages/flint-js/src/excel/templates/ import { vlTemplateDefs } from '../packages/flint-js/src/vegalite/templates/index'; import { ecTemplateDefs } from '../packages/flint-js/src/echarts/templates/index'; import { cjsTemplateDefs } from '../packages/flint-js/src/chartjs/templates/index'; +import { hcTemplateDefs } from '../packages/flint-js/src/highcharts/templates/index'; import { plTemplateDefs } from '../packages/flint-js/src/plotly/templates/index'; import { excelAllTemplateDefs } from '../packages/flint-js/src/excel/templates/index'; @@ -69,6 +70,16 @@ const BACKENDS: BackendSpec[] = [ 'The Chart.js backend is the lightweight embedding target for common chart families. It keeps the ' + 'parameter surface intentionally small.', }, + { + name: 'Highcharts', + file: 'reference-highcharts.md', + defs: hcTemplateDefs, + blurb: + 'The Highcharts backend compiles to a Highcharts options object ' + + '(`{ chart, xAxis, yAxis, series, tooltip, legend, colors }`) for SVG-rendered, ' + + 'interactive charts. It covers the common chart families with native ' + + 'Highcharts series types and datetime axes.', + }, { name: 'Plotly', file: 'reference-plotly.md', diff --git a/site/README.md b/site/README.md index 35a9cd34..a36db8c3 100644 --- a/site/README.md +++ b/site/README.md @@ -9,7 +9,7 @@ Routes (`HashRouter`): | Path | What it is | | ----------- | ----------------------------------------------------------- | | `#/` | Landing page (overview + links to GitHub and the two demos) | -| `#/gallery` | Every chart template across Vega-Lite / ECharts / Chart.js | +| `#/gallery` | Every chart template across Vega-Lite / ECharts / Chart.js / Highcharts | | `#/editor` | JSON ChartAssemblyInput playground, live-render per backend | ## Local development diff --git a/site/package.json b/site/package.json index db256eec..8a4bcfac 100644 --- a/site/package.json +++ b/site/package.json @@ -6,7 +6,7 @@ "description": "Unified flint-chart demo site: landing page, chart gallery, and live editor.", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && NODE_OPTIONS=--max-old-space-size=8192 vite build", "preview": "vite preview --port 4173", "typecheck": "tsc --noEmit" }, @@ -18,6 +18,7 @@ "d3": "^7.9.0", "echarts": "^6.0.0", "flint-chart": "*", + "highcharts": "^13.1.1", "i18next": "^26.3.6", "katex": "^0.17.0", "lucide-react": "^1.27.0", diff --git a/site/src/components/HighchartsView.tsx b/site/src/components/HighchartsView.tsx new file mode 100644 index 00000000..e93d24db --- /dev/null +++ b/site/src/components/HighchartsView.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from 'react'; +import Highcharts from 'highcharts'; +import 'highcharts/highcharts-more'; +import 'highcharts/modules/heatmap'; +import 'highcharts/modules/sunburst'; +import { siteTheme } from '../shared/theme'; + +const asFinite = (v: unknown): number | undefined => + typeof v === 'number' && Number.isFinite(v) ? v : undefined; + +/** + * Highcharts renderer. + * + * The flint Highcharts assembler designs a canvas (`_width`/`_height`) that + * reserves gutters for axes and legends. Render into a wrapper sized to those + * dimensions so the plot and legend keep their designed proportions — the + * same way Vega-Lite, ECharts, and Chart.js render at their natural designed + * size. + * + * Faceted charts (column/row encodings) are assembled into a `_facet` config + * that holds a grid of per-panel Highcharts options instead of a single one. + * Those are rendered by {@link HighchartsFacetView}; feeding the `_facet` + * config straight to a single `Highcharts.chart(...)` produces an empty chart + * because it has no top-level `chart`/`series`. + */ +export function HighchartsView({ + option, + height = 320, + constrain = true, +}: { + option: any; + height?: number; + /** When false, render at the designed pixel size without clamping to the + * container width (used by the photo-wall, which scales charts to fit). */ + constrain?: boolean; +}) { + if (option?._facet) { + return ; + } + return ; +} + +function HighchartsSingleView({ + option, + height, + constrain, +}: { + option: any; + height: number; + constrain: boolean; +}) { + const ref = useRef(null); + const chartRef = useRef(null); + const [error, setError] = useState(null); + + const designedWidth = asFinite(option?._width); + const designedHeight = asFinite(option?._height); + const renderHeight = designedHeight ?? height; + + useEffect(() => { + if (!ref.current) return; + + setError(null); + try { + if (chartRef.current) { + chartRef.current.destroy(); + chartRef.current = null; + } + chartRef.current = Highcharts.chart(ref.current, { + ...option, + chart: { + ...(option?.chart ?? {}), + width: designedWidth, + height: renderHeight, + }, + }); + } catch (err) { + // Highcharts may leave partial markup in the container on failure; + // clear it so the next chart starts clean. + if (ref.current) ref.current.innerHTML = ''; + setError(String((err as Error)?.message ?? err)); + } + }, [option, designedWidth, renderHeight]); + + useEffect(() => { + const chart = chartRef.current; + return () => { + try { + chart?.destroy(); + } catch { + // A chart that failed to initialize can throw on destroy; unmount + // must still complete. + } + chartRef.current = null; + }; + }, []); + + if (error) { + return ( +
+        {error}
+      
+ ); + } + + return ( +
+ ); +} + +/** Render a `_facet` config: one Highcharts chart per panel in a CSS grid. */ +function HighchartsFacetView({ + option, + constrain, +}: { + option: any; + constrain: boolean; +}) { + const panels: any[][] = option?._facetPanels ?? []; + const rows = option?._facetRows ?? panels.length; + const cols = option?._facetCols ?? Math.max(1, ...panels.map((r) => r.length)); + const gap = 16; + + return ( +
+ {panels.flatMap((row, ri) => + row.map((panel, ci) => ( +
+ {panel.colVal != null && panel.colVal !== '' && ( +
+ {panel.colVal} +
+ )} + {panel.rowVal != null && panel.rowVal !== '' && ( +
+ {panel.rowVal} +
+ )} + +
+ )), + )} +
+ ); +} diff --git a/site/src/components/InteractiveHighchartsView.tsx b/site/src/components/InteractiveHighchartsView.tsx new file mode 100644 index 00000000..de621154 --- /dev/null +++ b/site/src/components/InteractiveHighchartsView.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ChartAssemblyInput, ChartWarning } from 'flint-chart'; +import { buildInteractiveChart } from 'flint-chart/interactive'; +import { siteTheme } from '../shared/theme'; + +interface InteractiveHighchartsViewProps { + input: ChartAssemblyInput; + chartId?: string; + ariaLabel?: string; +} + +/** Mounts a Highcharts chart from its input through `buildInteractiveChart`, so `interaction_spec` takes effect. */ +export function InteractiveHighchartsView({ input, chartId, ariaLabel }: InteractiveHighchartsViewProps) { + const ref = useRef(null); + const [warnings, setWarnings] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const host = ref.current; + if (!host) return; + let live = true; + setWarnings([]); + setError(null); + let surface: ReturnType; + try { + surface = buildInteractiveChart(host, input, { backend: 'highcharts', chartId, ariaLabel }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return; + } + void surface.warnings.then((list) => { + if (live) setWarnings(list); + }); + void surface.ready.catch((err) => { + if (live) setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + live = false; + surface.destroy(); + }; + }, [input, chartId, ariaLabel]); + + return ( +
+
+ {error && ( +
{error}
+ )} + {warnings.length > 0 && ( +
    + {warnings.map((warning, index) => ( +
  • + {warning.severity}{' '} + {warning.message} +
  • + ))} +
+ )} +
+ ); +} diff --git a/site/src/playground/OverflowViewportLab.tsx b/site/src/playground/OverflowViewportLab.tsx index ebca8a48..7f0d2da5 100644 --- a/site/src/playground/OverflowViewportLab.tsx +++ b/site/src/playground/OverflowViewportLab.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import type { ChartAssemblyInput } from 'flint-chart'; -import { buildInteractiveChart } from 'flint-chart/interactive'; +import { buildInteractiveChart, type InteractiveBackend } from 'flint-chart/interactive'; import { expressionInterpreter } from 'vega-interpreter'; import { BACKENDS, getSupportedBackends, type PreviewBackend } from '../shared/supported-backends'; import './overflow-viewport-lab.css'; @@ -55,7 +55,7 @@ const heatmapInput: ChartAssemblyInput = { function InteractiveBackendSurface({ input, backend, renderer = 'canvas' }: { input: ChartAssemblyInput; - backend: PreviewBackend; + backend: InteractiveBackend; renderer?: 'canvas' | 'svg'; }) { const containerRef = useRef(null); @@ -87,9 +87,9 @@ function BackendPicker({ availableBackends, onChange, }: { - value: PreviewBackend; - availableBackends: PreviewBackend[]; - onChange: (value: PreviewBackend) => void; + value: InteractiveBackend; + availableBackends: InteractiveBackend[]; + onChange: (value: InteractiveBackend) => void; }) { return (
@@ -117,8 +117,11 @@ function GeneralViewportDemo({ initialBackend: PreviewBackend; description: string; }) { - const [backend, setBackend] = useState(initialBackend); - const availableBackends = getSupportedBackends(input.chart_spec.chartType); + const [backend, setBackend] = useState(initialBackend as InteractiveBackend); + // The interactive viewport surface is Vega-Lite-only; other backends have no + // interactive renderer, so the picker is narrowed to the interactive ones. + const availableBackends: InteractiveBackend[] = (['vegalite', 'echarts', 'chartjs', 'plotly'] as const) + .filter((b) => getSupportedBackends(input.chart_spec.chartType).includes(b)); useEffect(() => { if (!availableBackends.includes(backend)) { diff --git a/site/src/routes/ChartWall.tsx b/site/src/routes/ChartWall.tsx index d7206b4f..90add867 100644 --- a/site/src/routes/ChartWall.tsx +++ b/site/src/routes/ChartWall.tsx @@ -591,6 +591,7 @@ function BackendIntro({ vegalite: 'spec', echarts: 'option', chartjs: 'config', + highcharts: 'option', plotly: 'figure', }; const snippet = `import { ${category.fn} } from 'flint-chart'; diff --git a/site/src/routes/Editor.tsx b/site/src/routes/Editor.tsx index 64ea62b7..a35129cb 100644 --- a/site/src/routes/Editor.tsx +++ b/site/src/routes/Editor.tsx @@ -6,8 +6,10 @@ import { JsonCodeMirror } from '../components/JsonCodeMirror'; import { ResizeSplit } from '../components/ResizeSplit'; import { VegaLiteView } from '../components/VegaLiteView'; import { InteractiveVegaLiteView, hasInteractionEntries } from '../components/InteractiveVegaLiteView'; +import { InteractiveHighchartsView } from '../components/InteractiveHighchartsView'; import { EChartsView } from '../components/EChartsView'; import { ChartjsView } from '../components/ChartjsView'; +import { HighchartsView } from '../components/HighchartsView'; import { EXAMPLES } from './editor-examples'; import { loadEditorPayload, readEditorCaseParam, readGalleryCaseParams } from '../shared/editor-payload'; import { testCaseToAssemblyInput } from '../shared/test-case-utils'; @@ -306,6 +308,9 @@ function PreviewPane({ : )} {backend === 'echarts' && } {backend === 'chartjs' && } + {backend === 'highcharts' && (hasInteractionEntries(parsed.value) + ? + : )} ) : (
@@ -321,6 +326,7 @@ const OUTPUT_LANG: Record = {
   vegalite: 'Vega-Lite JSON',
   echarts: 'ECharts option',
   chartjs: 'Chart.js config',
+  highcharts: 'Highcharts option',
   plotly: 'Plotly figure',
 };
 
diff --git a/site/src/shared/docs-catalog.ts b/site/src/shared/docs-catalog.ts
index ac999049..4871ce9e 100644
--- a/site/src/shared/docs-catalog.ts
+++ b/site/src/shared/docs-catalog.ts
@@ -136,6 +136,12 @@ export const DOCUMENTATION_GROUPS: DocGroup[] = [
         description: 'Every Chart.js chart type, its channels, and configurable parameters.',
         file: '../../../docs/reference-chartjs.md',
       },
+      {
+        slug: 'reference-highcharts',
+        title: 'Highcharts charts',
+        description: 'Every Highcharts chart type, its channels, and configurable parameters.',
+        file: '../../../docs/reference-highcharts.md',
+      },
       {
         slug: 'reference-plotly',
         title: 'Plotly charts',
diff --git a/site/src/shared/supported-backends.ts b/site/src/shared/supported-backends.ts
index 961e220f..6b45bf0c 100644
--- a/site/src/shared/supported-backends.ts
+++ b/site/src/shared/supported-backends.ts
@@ -2,15 +2,17 @@ import {
   assembleVegaLite,
   assembleECharts,
   assembleChartjs,
+  assembleHighcharts,
   assemblePlotly,
   cjsGetTemplateDef,
   ecGetTemplateDef,
+  hcGetTemplateDef,
   vlGetTemplateDef,
   plGetTemplateDef,
   type ChartAssemblyInput,
 } from 'flint-chart';
 
-export type PreviewBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly';
+export type PreviewBackend = 'vegalite' | 'echarts' | 'chartjs' | 'highcharts' | 'plotly';
 
 /** A backend's template def (or `undefined` when it has no template for a type). */
 type TemplateDef = ReturnType;
@@ -53,6 +55,12 @@ export const BACKENDS: Record = {
     assemble: assembleChartjs,
     getTemplateDef: cjsGetTemplateDef,
   },
+  highcharts: {
+    id: 'highcharts',
+    label: 'Highcharts',
+    assemble: assembleHighcharts,
+    getTemplateDef: hcGetTemplateDef,
+  },
   plotly: {
     id: 'plotly',
     label: 'Plotly',

From 392f0a748258ad106466addcda8f93cc77c5f9c5 Mon Sep 17 00:00:00 2001
From: Sam 
Date: Tue, 20 Oct 2026 15:36:37 +0530
Subject: [PATCH 2/4] Fixed the copilot reported findings

---
 docs/reference-highcharts.md                  |   4 +-
 packages/flint-js/package.json                |   5 +
 packages/flint-js/src/highcharts/assemble.ts  |  53 +++-
 packages/flint-js/src/highcharts/index.ts     |   1 -
 .../src/highcharts/instantiate-spec.ts        |  27 +-
 .../flint-js/src/highcharts/interactive.ts    | 262 +++++++++++-------
 .../flint-js/src/highcharts/templates/bar.ts  | 103 ++-----
 .../flint-js/src/highcharts/templates/line.ts |  16 +-
 .../flint-js/src/highcharts/templates/pie.ts  |  10 +-
 .../src/highcharts/templates/radar.ts         |   7 +-
 .../src/highcharts/templates/scatter.ts       | 100 +++++--
 .../src/highcharts/templates/waterfall.ts     |   8 +-
 packages/flint-js/src/interactive/index.ts    |   6 +-
 .../flint-js/src/interactive/spec/compose.ts  |   2 +-
 packages/flint-js/src/validate/index.ts       |  14 +-
 .../flint-js/tests/highcharts-backend.test.ts |  12 +-
 packages/flint-js/tsup.config.ts              |   1 +
 packages/flint-mcp/src/render/highcharts.ts   | 225 +++++++++++++--
 packages/flint-mcp/src/render/index.ts        |  12 +
 packages/flint-mcp/tests/render.test.ts       |  23 +-
 site/src/components/HighchartsView.tsx        |  35 ++-
 site/src/components/TripleChart.tsx           |   2 +
 site/src/components/WallChart.tsx             |   2 +
 site/src/routes/Landing.tsx                   |   6 +-
 24 files changed, 643 insertions(+), 293 deletions(-)

diff --git a/docs/reference-highcharts.md b/docs/reference-highcharts.md
index c18aa69f..ba61b719 100644
--- a/docs/reference-highcharts.md
+++ b/docs/reference-highcharts.md
@@ -49,9 +49,7 @@ _No template-specific parameters._
 
 **Encoding channels:** `x`, `y`, `group`, `color`, `column`, `row`
 
-| Parameter | Control | Domain | Default | Availability | Description |
-|---|---|---|---|---|---|
-| `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge |
+_No template-specific parameters._
 
 ### ![](chart-icon-column-stacked.svg) Stacked Bar Chart
 
diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json
index d68c32e3..869c161a 100644
--- a/packages/flint-js/package.json
+++ b/packages/flint-js/package.json
@@ -100,6 +100,11 @@
       "import": "./dist/chartjs/interactive.js",
       "require": "./dist/chartjs/interactive.cjs"
     },
+    "./highcharts/interactive": {
+      "types": "./dist/highcharts/interactive.d.ts",
+      "import": "./dist/highcharts/interactive.js",
+      "require": "./dist/highcharts/interactive.cjs"
+    },
     "./plotly/interactive": {
       "types": "./dist/plotly/interactive.d.ts",
       "import": "./dist/plotly/interactive.js",
diff --git a/packages/flint-js/src/highcharts/assemble.ts b/packages/flint-js/src/highcharts/assemble.ts
index 4b3decb2..222be701 100644
--- a/packages/flint-js/src/highcharts/assemble.ts
+++ b/packages/flint-js/src/highcharts/assemble.ts
@@ -57,10 +57,17 @@ import { normalizeChartProperties } from '../core/normalize-properties';
 // ---------------------------------------------------------------------------
 
 function applyFieldDisplayNames(option: any, names: Record | undefined): void {
-    if (!names) return;
+    if (!names || !option) return;
+    if (option._facetPanels) {
+        for (const row of option._facetPanels) {
+            for (const panel of row) {
+                if (panel?.option) applyFieldDisplayNames(panel.option, names);
+            }
+        }
+    }
     const displayName = (value: unknown) => typeof value === 'string' ? names[value] ?? value : value;
     for (const axisKey of ['xAxis', 'yAxis']) {
-        const axes = Array.isArray(option[axisKey]) ? option[axisKey] : [option[axisKey]];
+        const axes = Array.isArray(option[axisKey]) ? option[axisKey] : (option[axisKey] ? [option[axisKey]] : []);
         for (const axis of axes) {
             if (axis?.title?.text) axis.title.text = displayName(axis.title.text);
         }
@@ -102,7 +109,13 @@ export function assembleHighcharts(input: ChartAssemblyInput): any {
     const normalizedProps = normalizeChartProperties(
         chartTemplate.properties, input.chart_spec.chartProperties,
     );
-    const chartProperties = normalizedProps.chartProperties;
+    const chartProperties = normalizedProps.chartProperties ?? {};
+    if (input.chart_spec.title !== undefined && chartProperties.title === undefined) {
+        chartProperties.title = input.chart_spec.title;
+    }
+    if (input.chart_spec.subtitle !== undefined && chartProperties.subtitle === undefined) {
+        chartProperties.subtitle = input.chart_spec.subtitle;
+    }
     warnings.push(...normalizedProps.warnings);
 
     // ═══════════════════════════════════════════════════════════════════════
@@ -265,22 +278,23 @@ export function assembleHighcharts(input: ChartAssemblyInput): any {
     const colField = channelSemantics.column?.field;
     const rowField = channelSemantics.row?.field;
     const hasFacet = !!(colField || rowField);
-    const hasAxes = chartTemplate.channels.includes('x') || chartTemplate.channels.includes('y');
 
     let hcOption: any;
 
-    if (hasFacet && hasAxes) {
+    if (hasFacet) {
         // ── Faceted rendering — Highcharts has no native small multiples ──
         // Emit a `_facet` config with a grid of per-panel options (same
         // contract as the Chart.js backend).
         const maxColsPerRow = facetGridResult?.columns ?? 1;
 
         let colValues: string[];
-        if (colField && channelSemantics.column?.type === 'quantitative') {
+        if (!colField) {
+            colValues = [''];
+        } else if (channelSemantics.column?.type === 'quantitative') {
             const raw = values.map((r: any) => r[colField]).filter((v: any) => v != null && typeof v === 'number' && !isNaN(v));
             colValues = [...new Set(raw)].map(String);
         } else {
-            colValues = colField ? [...new Set(values.map((r: any) => String(r[colField])))] : [];
+            colValues = [...new Set(values.map((r: any) => String(r[colField])))];
         }
 
         const wrapColumnOnly = !!colField && !rowField && maxColsPerRow < colValues.length;
@@ -363,6 +377,31 @@ export function assembleHighcharts(input: ChartAssemblyInput): any {
         hcOption._pivot = legacyPivot.surface;
     }
 
+    if (hasFacet) {
+        if (input.chart_spec.title !== undefined) {
+            hcOption.title = { text: input.chart_spec.title };
+        }
+        if (input.chart_spec.subtitle !== undefined) {
+            hcOption.subtitle = { text: input.chart_spec.subtitle };
+        }
+    }
+
+    const hasLegend = !hasFacet && !!(hcOption.legend && hcOption.legend.enabled !== false && (hcOption.series?.length > 1 || channelSemantics.color?.field || channelSemantics.group?.field));
+    const navAxes: ('x' | 'y')[] = [];
+    if (!hasFacet) {
+        if (channelSemantics.x?.type === 'quantitative' || channelSemantics.x?.type === 'temporal') navAxes.push('x');
+        if (channelSemantics.y?.type === 'quantitative' || channelSemantics.y?.type === 'temporal') navAxes.push('y');
+    }
+    const capabilities: import('../core/interaction-spec').InteractionCapability[] = [];
+    if (hasLegend) capabilities.push('legend');
+    if (navAxes.length > 0) capabilities.push('navigation');
+
+    hcOption._interactionSemantics = {
+        chartType,
+        capabilities,
+        navigationAxes: navAxes,
+    };
+
     applyFieldDisplayNames(hcOption, input.field_display_names);
 
     return hcOption;
diff --git a/packages/flint-js/src/highcharts/index.ts b/packages/flint-js/src/highcharts/index.ts
index f3482c69..a5c60027 100644
--- a/packages/flint-js/src/highcharts/index.ts
+++ b/packages/flint-js/src/highcharts/index.ts
@@ -9,4 +9,3 @@ export { assembleHighcharts, getHighchartsPivot, getHighchartsTransform } from '
 export { hcApplyLayoutToSpec, hcApplyTooltips } from './instantiate-spec';
 export { hcTemplateDefs, hcAllTemplateDefs, hcGetTemplateDef, hcGetTemplateChannels } from './templates';
 export { getPaletteForScheme, pickHighchartsPalette } from './colormap';
-export { createHighchartsInteractiveRenderer } from './interactive';
diff --git a/packages/flint-js/src/highcharts/instantiate-spec.ts b/packages/flint-js/src/highcharts/instantiate-spec.ts
index 2a305637..6bd352e4 100644
--- a/packages/flint-js/src/highcharts/instantiate-spec.ts
+++ b/packages/flint-js/src/highcharts/instantiate-spec.ts
@@ -85,19 +85,22 @@ export function hcApplyLayoutToSpec(
     }
 
     // ── Zero-baseline and domain padding (value axes) ───────────────────────
-    for (const axis of ['x', 'y'] as const) {
-        const axisObj = option[`${axis}Axis`];
+    const isInverted = option.chart?.type === 'bar' || option.chart?.inverted === true;
+    for (const hcAxis of ['x', 'y'] as const) {
+        const axisObj = option[`${hcAxis}Axis`];
         if (!axisObj) continue;
-        const cs = channelSemantics[axis];
+        const flintChannel = isInverted ? (hcAxis === 'x' ? 'y' : 'x') : hcAxis;
+        const cs = channelSemantics[flintChannel];
         if (!cs?.zero) continue;
         const decision = cs.zero;
         // Highcharts category axes handle zero via the numeric y/x axis; only
-        // pin min: 0 when the zero decision says to include zero and the axis
+        // pin soft zero bounds when the zero decision says to include zero and the axis
         // is numeric (not category/datetime).
         const isNumericAxis = !axisObj.type || axisObj.type === 'linear' || axisObj.type === 'value' || axisObj.type === 'datetime' || axisObj.type === 'logarithmic';
         if (!isNumericAxis) continue;
-        if (decision.zero && axisObj.min === undefined) {
-            axisObj.min = 0;
+        if (decision.zero) {
+            if (axisObj.softMin === undefined && axisObj.min === undefined) axisObj.softMin = 0;
+            if (axisObj.softMax === undefined && axisObj.max === undefined) axisObj.softMax = 0;
         }
         if (!decision.zero && decision.domainPadFraction > 0 && cs.field) {
             const numericValues = context.table
@@ -132,11 +135,15 @@ export function hcApplyLayoutToSpec(
     }
 
     // ── Title / subtitle ────────────────────────────────────────────────────
-    if (context.chartProperties?.title != null) {
-        option.title = { text: String(context.chartProperties.title) };
+    const titleText = context.chartProperties?.title;
+    if (titleText != null && titleText !== '') {
+        option.title = { text: String(titleText) };
+    } else {
+        option.title = { text: null };
     }
-    if (context.chartProperties?.subtitle != null) {
-        option.subtitle = { text: String(context.chartProperties.subtitle) };
+    const subtitleText = context.chartProperties?.subtitle;
+    if (subtitleText != null && subtitleText !== '') {
+        option.subtitle = { text: String(subtitleText) };
     }
 
     // ── Color scheme ────────────────────────────────────────────────────────
diff --git a/packages/flint-js/src/highcharts/interactive.ts b/packages/flint-js/src/highcharts/interactive.ts
index 58345521..6c2e7245 100644
--- a/packages/flint-js/src/highcharts/interactive.ts
+++ b/packages/flint-js/src/highcharts/interactive.ts
@@ -1,4 +1,7 @@
 import Highcharts from 'highcharts';
+import highchartsMore from 'highcharts/highcharts-more';
+import heatmapModule from 'highcharts/modules/heatmap';
+import sunburstModule from 'highcharts/modules/sunburst';
 import { applyCategoryViewports } from '../core/filter-overflow';
 import type { CategoryViewport, ChartAssemblyInput } from '../core/types';
 import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types';
@@ -6,6 +9,17 @@ import type { ChartUpdate, ChartUpdateResult } from '../interactive/language/upd
 import type { SemanticElement } from '../core/interaction-contracts';
 import { assembleHighcharts } from './assemble';
 
+function initModule(mod: any) {
+    if (typeof mod === 'function') {
+        mod(Highcharts);
+    } else if (mod && typeof mod.default === 'function') {
+        mod.default(Highcharts);
+    }
+}
+initModule(highchartsMore);
+initModule(heatmapModule);
+initModule(sunburstModule);
+
 function windowedInput(
     input: ChartAssemblyInput,
     viewports: CategoryViewport[],
@@ -26,7 +40,11 @@ interface ActiveSelection {
 
 const DIM_OPACITY = 0.15;
 
-export function createHighchartsInteractiveRenderer(): InteractiveRendererAdapter {
+export interface HighchartsRendererOptions {
+    interactions?: any[];
+}
+
+export function createHighchartsInteractiveRenderer(rendererOptions?: HighchartsRendererOptions): InteractiveRendererAdapter {
     return {
         async mount(container, input) {
             const plannedOption = assembleHighcharts(input) as any;
@@ -43,110 +61,157 @@ export function createHighchartsInteractiveRenderer(): InteractiveRendererAdapte
             container.append(wrapper);
 
             let activeSelection: ActiveSelection | null = null;
-            let currentChart: Highcharts.Chart | null = null;
+            let currentCharts: Highcharts.Chart[] = [];
             let destroyed = false;
             let updateTimer: number | undefined;
             let latestStarts: ViewportState = {};
             let lastOptionJson = '';
 
             const applyVisualStates = (): void => {
-                if (!currentChart) return;
-                currentChart.series.forEach((series, seriesIndex) => {
-                    const points = (series.points ?? []) as Array) => void };
-                    }>;
-                    const seriesOpacity = activeSelection
-                        ? (seriesIndex === activeSelection.series ? 1 : DIM_OPACITY)
-                        : 1;
-                    points.forEach((point) => {
-                        if (point.graphic && typeof point.graphic.attr === 'function') {
-                            point.graphic.attr({ opacity: seriesOpacity });
-                        }
-                    });
-                    const parts = [
-                        (series as { graph?: { attr: (a: Record) => void } }).graph,
-                        (series as { area?: { attr: (a: Record) => void } }).area,
-                    ];
-                    parts.forEach((part) => {
-                        if (part && typeof part.attr === 'function') {
-                            part.attr({ opacity: seriesOpacity });
-                        }
+                for (const chart of currentCharts) {
+                    chart.series.forEach((series, seriesIndex) => {
+                        const points = (series.points ?? []) as Array) => void };
+                        }>;
+                        const seriesOpacity = activeSelection
+                            ? (seriesIndex === activeSelection.series ? 1 : DIM_OPACITY)
+                            : 1;
+                        points.forEach((point) => {
+                            if (point.graphic && typeof point.graphic.attr === 'function') {
+                                point.graphic.attr({ opacity: seriesOpacity });
+                            }
+                        });
+                        const parts = [
+                            (series as { graph?: { attr: (a: Record) => void } }).graph,
+                            (series as { area?: { attr: (a: Record) => void } }).area,
+                        ];
+                        parts.forEach((part) => {
+                            if (part && typeof part.attr === 'function') {
+                                part.attr({ opacity: seriesOpacity });
+                            }
+                        });
                     });
-                });
+                }
             };
 
             const draw = (option: any): void => {
                 const optionJson = JSON.stringify(option);
-                if (optionJson === lastOptionJson && currentChart && !destroyed) return;
-                const isFirstDraw = !currentChart || lastOptionJson === '';
+                if (optionJson === lastOptionJson && currentCharts.length > 0 && !destroyed) return;
                 lastOptionJson = optionJson;
-                if (isFirstDraw) {
-                    wrapper.innerHTML = '';
-                }
-                if (currentChart && !destroyed) {
-                    try {
-                        currentChart.destroy();
-                    } catch {
-                        // ignore teardown errors
+
+                if (currentCharts.length > 0) {
+                    for (const c of currentCharts) {
+                        try {
+                            c.destroy();
+                        } catch {
+                            // ignore teardown errors
+                        }
                     }
-                    currentChart = null;
+                    currentCharts = [];
                 }
-                const el = document.createElement('div');
-                el.style.width = '100%';
-                el.style.height = '100%';
-                if (!isFirstDraw) {
-                    const oldContainers = wrapper.querySelectorAll('[data-highcharts-chart]');
-                    oldContainers.forEach(c => c.remove());
-                }
-                wrapper.append(el);
+                wrapper.replaceChildren();
 
-                const plotOptions = option?.plotOptions ?? {};
-                option = {
-                    ...option,
-                    plotOptions: {
-                        ...plotOptions,
-                        series: {
-                            ...(plotOptions.series ?? {}),
-                            cursor: 'pointer',
-                            states: {
-                                ...(plotOptions.series?.states ?? {}),
-                                inactive: { enabled: false },
-                            },
-                            point: {
-                                ...(plotOptions.series?.point ?? {}),
-                                events: {
-                                    ...(plotOptions.series?.point?.events ?? {}),
-                                    click: function (this: Highcharts.Point) {
-                                        const point = this as Highcharts.Point;
-                                        const seriesIndex = point.series?.index ?? 0;
-                                        const pointIndex = point.index ?? -1;
+                const isFaceted = option?._facet && Array.isArray(option?._facetPanels);
 
-                                        if (activeSelection && activeSelection.series === seriesIndex) {
-                                            activeSelection = null;
-                                        } else {
-                                            activeSelection = { series: seriesIndex, point: pointIndex };
-                                        }
-                                        applyVisualStates();
-                                    },
-                                    mouseOver: function () {
-                                        applyVisualStates();
-                                    },
-                                    mouseOut: function () {
-                                        window.setTimeout(applyVisualStates, 0);
+                const decorateOption = (panelOpt: any) => {
+                    const plotOptions = panelOpt?.plotOptions ?? {};
+                    const isZoomable = rendererOptions?.interactions?.some((it: any) => it.eventSource?.type === 'navigation');
+                    return {
+                        ...panelOpt,
+                        chart: {
+                            ...(panelOpt?.chart ?? {}),
+                            animation: false,
+                            ...(isZoomable ? { zoomType: 'x' } : {}),
+                        },
+                        plotOptions: {
+                            ...plotOptions,
+                            series: {
+                                ...(plotOptions.series ?? {}),
+                                cursor: 'pointer',
+                                states: {
+                                    ...(plotOptions.series?.states ?? {}),
+                                    inactive: { enabled: false },
+                                },
+                                point: {
+                                    ...(plotOptions.series?.point ?? {}),
+                                    events: {
+                                        ...(plotOptions.series?.point?.events ?? {}),
+                                        click: function (this: Highcharts.Point) {
+                                            const point = this as Highcharts.Point;
+                                            const seriesIndex = point.series?.index ?? 0;
+                                            const pointIndex = point.index ?? -1;
+
+                                            if (activeSelection && activeSelection.series === seriesIndex) {
+                                                activeSelection = null;
+                                            } else {
+                                                activeSelection = { series: seriesIndex, point: pointIndex };
+                                            }
+                                            applyVisualStates();
+                                        },
+                                        mouseOver: function () {
+                                            applyVisualStates();
+                                        },
+                                        mouseOut: function () {
+                                            window.setTimeout(applyVisualStates, 0);
+                                        },
                                     },
                                 },
                             },
                         },
-                    },
+                    };
                 };
 
-                currentChart = Highcharts.chart(el, {
-                    ...option,
-                    chart: {
-                        ...(option?.chart ?? {}),
-                        animation: false,
-                    },
-                });
+                if (isFaceted) {
+                    const grid = document.createElement('div');
+                    const cols = option._facetCols ?? Math.max(1, ...option._facetPanels.map((r: any) => r.length));
+                    grid.style.display = 'grid';
+                    grid.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
+                    grid.style.gap = '16px';
+                    grid.style.width = '100%';
+                    wrapper.append(grid);
+
+                    for (let ri = 0; ri < option._facetPanels.length; ri++) {
+                        const row = option._facetPanels[ri];
+                        for (let ci = 0; ci < row.length; ci++) {
+                            const panel = row[ci];
+                            const panelDiv = document.createElement('div');
+                            panelDiv.style.minWidth = '0';
+                            if (panel.colVal != null && panel.colVal !== '') {
+                                const h = document.createElement('div');
+                                h.style.fontSize = '11px';
+                                h.style.fontWeight = '600';
+                                h.style.color = '#555';
+                                h.style.marginBottom = '2px';
+                                h.textContent = panel.colVal;
+                                panelDiv.append(h);
+                            }
+                            if (panel.rowVal != null && panel.rowVal !== '') {
+                                const h = document.createElement('div');
+                                h.style.fontSize = '11px';
+                                h.style.fontWeight = '600';
+                                h.style.color = '#555';
+                                h.style.marginBottom = '2px';
+                                h.textContent = panel.rowVal;
+                                panelDiv.append(h);
+                            }
+                            const chartEl = document.createElement('div');
+                            chartEl.style.width = '100%';
+                            chartEl.style.height = `${Number.isFinite(panel.option?._height) ? panel.option._height : 300}px`;
+                            panelDiv.append(chartEl);
+                            grid.append(panelDiv);
+
+                            const chart = Highcharts.chart(chartEl, decorateOption(panel.option));
+                            currentCharts.push(chart);
+                        }
+                    }
+                } else {
+                    const el = document.createElement('div');
+                    el.style.width = '100%';
+                    el.style.height = '100%';
+                    wrapper.append(el);
+                    const chart = Highcharts.chart(el, decorateOption(option));
+                    currentCharts.push(chart);
+                }
 
                 applyVisualStates();
             };
@@ -166,22 +231,23 @@ export function createHighchartsInteractiveRenderer(): InteractiveRendererAdapte
             return {
                 viewports,
                 getViewportGeometry(channel) {
-                    const plot = wrapper.firstElementChild as HTMLElement | null;
-                    if (!plot) return undefined;
-                    const rect = plot.getBoundingClientRect();
+                    const chart = currentCharts[0];
+                    if (!chart || chart.plotLeft == null || chart.plotWidth == null) return undefined;
                     return channel === 'x'
-                        ? { offset: rect.left, extent: rect.width }
-                        : { offset: rect.top, extent: rect.height };
+                        ? { offset: chart.plotLeft, extent: chart.plotWidth }
+                        : { offset: chart.plotTop, extent: chart.plotHeight };
                 },
                 getInteractionContext() {
                     const selected: SemanticElement[] = [];
-                    if (activeSelection && currentChart) {
-                        const series = currentChart.series?.[activeSelection.series];
-                        const point = series?.points?.[activeSelection.point];
-                        if (point?.options && typeof point.options === 'object') {
-                            const row = { ...(point.options as Record) };
-                            delete row._seriesIndex;
-                            selected.push({ value: row, records: [row] });
+                    if (activeSelection) {
+                        for (const chart of currentCharts) {
+                            const series = chart.series?.[activeSelection.series];
+                            const point = series?.points?.[activeSelection.point];
+                            if (point?.options && typeof point.options === 'object') {
+                                const row = { ...(point.options as Record) };
+                                delete row._seriesIndex;
+                                selected.push({ value: row, records: [row] });
+                            }
                         }
                     }
                     return {
@@ -231,14 +297,14 @@ export function createHighchartsInteractiveRenderer(): InteractiveRendererAdapte
                     if (destroyed) return;
                     destroyed = true;
                     if (updateTimer !== undefined) window.clearTimeout(updateTimer);
-                    if (currentChart) {
+                    for (const chart of currentCharts) {
                         try {
-                            currentChart.destroy();
+                            chart.destroy();
                         } catch {
                             // ignore teardown errors
                         }
-                        currentChart = null;
                     }
+                    currentCharts = [];
                     wrapper.remove();
                 },
             };
diff --git a/packages/flint-js/src/highcharts/templates/bar.ts b/packages/flint-js/src/highcharts/templates/bar.ts
index b0c43d98..bd582934 100644
--- a/packages/flint-js/src/highcharts/templates/bar.ts
+++ b/packages/flint-js/src/highcharts/templates/bar.ts
@@ -24,7 +24,6 @@ import {
     detectBandedAxisFromSemantics,
     detectBandedAxisForceDiscrete,
 } from '../../core/axis-detection';
-import { planBandDodge, resolveDodge } from '../../core/band-dodge';
 import { makeCartesianPivot } from '../../core/pivot';
 import { makeSortAction } from '../../core/encoding-actions';
 
@@ -131,16 +130,12 @@ export const hcBarChartDef: ChartTemplateDef = {
 
             const option: any = {
                 chart: { type: isHorizontal ? 'bar' : 'column' },
-                xAxis: isHorizontal
-                    ? { title: { text: valField } }
-                    : {
-                        categories,
-                        title: { text: catField },
-                        labels: { rotation: categoryAxisLabelRotateDeg(categories, catCS?.type) },
-                    },
-                yAxis: isHorizontal
-                    ? { categories, title: { text: catField } }
-                    : { title: { text: valField } },
+                xAxis: {
+                    categories,
+                    title: { text: catField },
+                    labels: { rotation: isHorizontal ? 0 : categoryAxisLabelRotateDeg(categories, catCS?.type) },
+                },
+                yAxis: { title: { text: valField } },
                 legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' },
                 series,
             };
@@ -169,16 +164,12 @@ export const hcBarChartDef: ChartTemplateDef = {
 
         const option: any = {
             chart: { type: isHorizontal ? 'bar' : 'column' },
-            xAxis: isHorizontal
-                ? { title: { text: valueLabel } }
-                : {
-                    categories,
-                    title: { text: catField },
-                    labels: { rotation: categoryAxisLabelRotateDeg(categories, catCS?.type) },
-                },
-            yAxis: isHorizontal
-                ? { categories, title: { text: catField } }
-                : { title: { text: valueLabel } },
+            xAxis: {
+                categories,
+                title: { text: catField },
+                labels: { rotation: isHorizontal ? 0 : categoryAxisLabelRotateDeg(categories, catCS?.type) },
+            },
+            yAxis: { title: { text: valueLabel } },
             legend: { enabled: false },
             series: [{
                 name: valueLabel,
@@ -224,7 +215,7 @@ export const hcStackedBarChartDef: ChartTemplateDef = {
         };
     },
     instantiate: (spec, ctx) => {
-        const { channelSemantics, table } = ctx;
+        const { channelSemantics, table, chartProperties } = ctx;
         const { categoryAxis, valueAxis } = detectAxes(channelSemantics);
         const colorField = channelSemantics.color?.field;
 
@@ -260,7 +251,7 @@ export const hcStackedBarChartDef: ChartTemplateDef = {
                 },
                 yAxis: { title: { text: 'Count' }, min: 0, allowDecimals: false },
                 legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' },
-                plotOptions: { series: { stacking: 'normal' } },
+                plotOptions: { series: { stacking: chartProperties?.stackMode === 'percent' ? 'percent' : 'normal' } },
                 series,
             };
             Object.assign(spec, option);
@@ -269,20 +260,17 @@ export const hcStackedBarChartDef: ChartTemplateDef = {
             return;
         }
 
+        const stackMode = chartProperties?.stackMode === 'percent' ? 'percent' : 'normal';
         const option: any = {
             chart: { type: isHorizontal ? 'bar' : 'column' },
-            xAxis: isHorizontal
-                ? { title: { text: valueLabel } }
-                : {
-                    categories,
-                    title: { text: catField },
-                    labels: { rotation: categoryAxisLabelRotateDeg(categories, catCS?.type) },
-                },
-            yAxis: isHorizontal
-                ? { categories, title: { text: catField } }
-                : { title: { text: valueLabel } },
+            xAxis: {
+                categories,
+                title: { text: catField },
+                labels: { rotation: isHorizontal ? 0 : categoryAxisLabelRotateDeg(categories, catCS?.type) },
+            },
+            yAxis: { title: { text: valueLabel } },
             legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' },
-            plotOptions: { series: { stacking: 'normal' } },
+            plotOptions: { series: { stacking: stackMode } },
             series: [],
         };
 
@@ -330,20 +318,13 @@ export const hcGroupedBarChartDef: ChartTemplateDef = {
     template: { mark: 'bar', encoding: {} },
     channels: ['x', 'y', 'group', 'color', 'column', 'row'],
     markCognitiveChannel: 'length',
-    declareLayoutMode: (cs, table, chartProperties) => {
+    declareLayoutMode: (cs, table, _chartProperties) => {
         const result = detectBandedAxisForceDiscrete(cs, table, { preferAxis: 'x' });
         const axis = result?.axis || 'x';
         const decl: import('../../core/types').LayoutDeclaration = {
             axisFlags: { [axis]: { banded: true } },
             resolvedTypes: result?.resolvedTypes,
         };
-        const groupField = cs.group?.field || cs.color?.field;
-        const axisField = cs[axis]?.field;
-        if (groupField && axisField) {
-            const plan = planBandDodge(table, axisField, groupField);
-            const { mode } = resolveDodge(plan, chartProperties?.dodge);
-            if (mode === 'local') decl.groupLaneCount = Math.max(1, plan.maxPerBand);
-        }
         return decl;
     },
     instantiate: (spec, ctx) => {
@@ -436,16 +417,12 @@ export const hcGroupedBarChartDef: ChartTemplateDef = {
 
         const option: any = {
             chart: { type: isHorizontal ? 'bar' : 'column' },
-            xAxis: isHorizontal
-                ? { title: { text: valField } }
-                : {
-                    categories,
-                    title: { text: catField },
-                    labels: { rotation: categoryAxisLabelRotateDeg(categories, catCS?.type) },
-                },
-            yAxis: isHorizontal
-                ? { categories, title: { text: catField } }
-                : { title: { text: valField } },
+            xAxis: {
+                categories,
+                title: { text: catField },
+                labels: { rotation: isHorizontal ? 0 : categoryAxisLabelRotateDeg(categories, catCS?.type) },
+            },
+            yAxis: { title: { text: valField } },
             legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' },
             series: [],
         };
@@ -465,27 +442,7 @@ export const hcGroupedBarChartDef: ChartTemplateDef = {
         delete spec.mark;
         delete spec.encoding;
     },
-    properties: [
-        {
-            key: 'dodge', label: 'Dodge', type: 'discrete',
-            options: [
-                { value: 'auto', label: 'Auto' },
-                { value: 'local', label: 'Local (compact)' },
-                { value: 'global', label: 'Global (aligned)' },
-            ],
-            defaultValue: 'auto',
-            check: (ctx) => {
-                const groupField = ctx.channelSemantics?.group?.field ?? ctx.encodings?.group?.field;
-                const axisField = ctx.channelSemantics?.x?.type === 'nominal'
-                    ? ctx.channelSemantics?.x?.field
-                    : ctx.channelSemantics?.y?.field;
-                const rows = ctx.data;
-                if (!groupField || !axisField || !rows) return { applicable: false };
-                const plan = planBandDodge(rows, axisField, groupField);
-                return { applicable: plan.ambiguous, recommendedValue: plan.mode === 'none' ? 'auto' : plan.mode };
-            },
-        } as ChartPropertyDef,
-    ],
+    properties: [],
     encodingActions: [makeSortAction()] as EncodingActionDef[],
     pivot: makeCartesianPivot({
         transpose: [['x', 'y']],
diff --git a/packages/flint-js/src/highcharts/templates/line.ts b/packages/flint-js/src/highcharts/templates/line.ts
index 531b5db0..aac47376 100644
--- a/packages/flint-js/src/highcharts/templates/line.ts
+++ b/packages/flint-js/src/highcharts/templates/line.ts
@@ -86,6 +86,9 @@ function buildLineSeries(
 ): any[] {
     const xField = xCS.field;
     const { dataMode } = buildLineXAxis(ctx, xCS);
+    const allCategories = dataMode === 'category'
+        ? extractCategories(table, xField, getCategoryOrder(ctx, 'x'))
+        : [];
 
     const series: any[] = [];
 
@@ -95,7 +98,7 @@ function buildLineSeries(
             series.push({
                 name,
                 data: dataMode === 'category'
-                    ? buildCategoryValues(rows, xField, yField, extractCategories(rows, xField, getCategoryOrder(ctx, 'x')))
+                    ? buildCategoryValues(rows, xField, yField, allCategories)
                     : buildPairs(rows, xField, yField, xCS.type === 'temporal'),
                 ...(sizeField ? { marker: { radius: 5 } } : {}),
             });
@@ -106,7 +109,7 @@ function buildLineSeries(
     series.push({
         name: yField,
         data: dataMode === 'category'
-            ? buildCategoryValues(table, xField, yField, extractCategories(table, xField, getCategoryOrder(ctx, 'x')))
+            ? buildCategoryValues(table, xField, yField, allCategories)
             : buildPairs(table, xField, yField, xCS.type === 'temporal'),
         ...(sizeField ? { marker: { radius: 5 } } : {}),
     });
@@ -185,7 +188,7 @@ export const hcAreaChartDef: ChartTemplateDef = {
         paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' } },
     }),
     instantiate: (spec, ctx) => {
-        const { channelSemantics, table } = ctx;
+        const { channelSemantics, table, chartProperties } = ctx;
         const xCS = channelSemantics.x;
         const yCS = channelSemantics.y;
         const colorField = channelSemantics.color?.field;
@@ -195,15 +198,18 @@ export const hcAreaChartDef: ChartTemplateDef = {
         const { xAxis } = buildLineXAxis(ctx, xCS);
         const series = buildLineSeries(table, ctx, xCS, yCS.field, colorField, undefined);
 
+        const isSmooth = chartProperties?.interpolate === 'monotone';
+        const chartType = isSmooth ? 'areaspline' : 'area';
+
         const option: any = {
-            chart: { type: 'area' },
+            chart: { type: chartType },
             xAxis,
             yAxis: { title: { text: yCS.field } },
             legend: colorField
                 ? { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }
                 : { enabled: false },
             plotOptions: {
-                area: {
+                [chartType]: {
                     marker: { enabled: false },
                     lineWidth: 1,
                     states: { hover: { lineWidth: 1 } },
diff --git a/packages/flint-js/src/highcharts/templates/pie.ts b/packages/flint-js/src/highcharts/templates/pie.ts
index 491000bf..c52dba08 100644
--- a/packages/flint-js/src/highcharts/templates/pie.ts
+++ b/packages/flint-js/src/highcharts/templates/pie.ts
@@ -76,11 +76,11 @@ function buildPieData(
         }
     }
 
-    sortSlicesInPlace(
-        slices.map(s => s.name),
-        slices.map(s => s.y),
-        sortSlices,
-    );
+    if (sortSlices === 'descending') {
+        slices.sort((a, b) => b.y - a.y);
+    } else if (sortSlices === 'ascending') {
+        slices.sort((a, b) => a.y - b.y);
+    }
     return slices;
 }
 
diff --git a/packages/flint-js/src/highcharts/templates/radar.ts b/packages/flint-js/src/highcharts/templates/radar.ts
index 425b7288..b8657918 100644
--- a/packages/flint-js/src/highcharts/templates/radar.ts
+++ b/packages/flint-js/src/highcharts/templates/radar.ts
@@ -34,6 +34,7 @@ export const hcRadarChartDef: ChartTemplateDef = {
         if (metrics.length < 2) return;
 
         const filled = chartProperties?.filled !== false;
+        const seriesType = filled ? 'area' : 'line';
 
         const series: any[] = [];
 
@@ -51,7 +52,7 @@ export const hcRadarChartDef: ChartTemplateDef = {
                 }
                 series.push({
                     name,
-                    type: 'line',
+                    type: seriesType,
                     data: metrics.map(m => {
                         const agg = metricVals.get(m);
                         return agg && agg.count > 0 ? agg.sum / agg.count : 0;
@@ -71,7 +72,7 @@ export const hcRadarChartDef: ChartTemplateDef = {
             }
             series.push({
                 name: valueField,
-                type: 'line',
+                type: seriesType,
                 data: metrics.map(m => {
                     const agg = metricVals.get(m);
                     return agg && agg.count > 0 ? agg.sum / agg.count : 0;
@@ -81,7 +82,7 @@ export const hcRadarChartDef: ChartTemplateDef = {
         }
 
         const option: any = {
-            chart: { polar: true, type: 'line' },
+            chart: { polar: true, type: seriesType },
             xAxis: {
                 categories: metrics,
                 tickmarkPlacement: 'on',
diff --git a/packages/flint-js/src/highcharts/templates/scatter.ts b/packages/flint-js/src/highcharts/templates/scatter.ts
index f6d3a7cc..62055803 100644
--- a/packages/flint-js/src/highcharts/templates/scatter.ts
+++ b/packages/flint-js/src/highcharts/templates/scatter.ts
@@ -6,33 +6,92 @@
  *
  * Highcharts mapping:
  *   VL: mark = "point" with x + y (+ color/size)
- *   HC: chart.type = 'scatter'; xAxis/yAxis are linear; optional size channel
- *       → bubble series type with z values.
+ *   HC: chart.type = 'scatter' (or 'bubble' when size channel is present).
+ *       Supports numeric, temporal, and categorical channels on both x and y.
  */
 
-import { ChartTemplateDef, EncodingActionDef } from '../../core/types';
-import { groupBy } from './utils';
+import { ChartTemplateDef, EncodingActionDef, ChannelSemantics } from '../../core/types';
+import { groupBy, extractCategories } from './utils';
 import { makeCartesianPivot } from '../../core/pivot';
 import { makeSortAction } from '../../core/encoding-actions';
 
-function buildScatterPairs(table: any[], xField: string, yField: string): [number, number][] {
+interface AxisMapping {
+    axisConfig: any;
+    toCoord: (val: any) => number | null;
+}
+
+function resolveScatterAxis(table: any[], cs: ChannelSemantics, _axis: 'x' | 'y'): AxisMapping {
+    if (cs.type === 'nominal' || cs.type === 'ordinal') {
+        const categories = extractCategories(table, cs.field!, cs.ordinalSortOrder);
+        return {
+            axisConfig: {
+                categories,
+                title: { text: cs.field },
+            },
+            toCoord: (val: any) => {
+                if (val == null) return null;
+                const idx = categories.indexOf(String(val));
+                return idx >= 0 ? idx : null;
+            },
+        };
+    }
+    if (cs.type === 'temporal') {
+        return {
+            axisConfig: {
+                type: 'datetime',
+                title: { text: cs.field },
+            },
+            toCoord: (val: any) => {
+                if (val == null) return null;
+                if (typeof val === 'number' && Number.isFinite(val)) return val;
+                const t = new Date(val).getTime();
+                return Number.isFinite(t) ? t : null;
+            },
+        };
+    }
+    return {
+        axisConfig: {
+            title: { text: cs.field },
+        },
+        toCoord: (val: any) => {
+            if (val == null) return null;
+            const n = Number(val);
+            return Number.isFinite(n) ? n : null;
+        },
+    };
+}
+
+function buildScatterPairs(
+    table: any[],
+    xField: string,
+    yField: string,
+    xToCoord: (v: any) => number | null,
+    yToCoord: (v: any) => number | null,
+): [number, number][] {
     const pairs: [number, number][] = [];
     for (const row of table) {
-        const x = Number(row[xField]);
-        const y = Number(row[yField]);
-        if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
+        const x = xToCoord(row[xField]);
+        const y = yToCoord(row[yField]);
+        if (x === null || y === null) continue;
         pairs.push([x, y]);
     }
     return pairs;
 }
 
-function buildBubblePoints(table: any[], xField: string, yField: string, sizeField: string): { x: number; y: number; z: number }[] {
+function buildBubblePoints(
+    table: any[],
+    xField: string,
+    yField: string,
+    sizeField: string,
+    xToCoord: (v: any) => number | null,
+    yToCoord: (v: any) => number | null,
+): { x: number; y: number; z: number }[] {
     const points: { x: number; y: number; z: number }[] = [];
     for (const row of table) {
-        const x = Number(row[xField]);
-        const y = Number(row[yField]);
+        const x = xToCoord(row[xField]);
+        const y = yToCoord(row[yField]);
         const z = Number(row[sizeField]);
-        if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
+        if (x === null || y === null) continue;
         points.push({ x, y, z: Number.isFinite(z) ? z : 1 });
     }
     return points;
@@ -53,6 +112,9 @@ export const hcScatterPlotDef: ChartTemplateDef = {
 
         if (!xCS?.field || !yCS?.field) return;
 
+        const xMap = resolveScatterAxis(table, xCS, 'x');
+        const yMap = resolveScatterAxis(table, yCS, 'y');
+
         const useBubble = !!sizeField;
         const series: any[] = [];
 
@@ -63,8 +125,8 @@ export const hcScatterPlotDef: ChartTemplateDef = {
                     name,
                     type: useBubble ? 'bubble' : 'scatter',
                     data: useBubble
-                        ? buildBubblePoints(rows, xCS.field, yCS.field, sizeField)
-                        : buildScatterPairs(rows, xCS.field, yCS.field),
+                        ? buildBubblePoints(rows, xCS.field, yCS.field, sizeField, xMap.toCoord, yMap.toCoord)
+                        : buildScatterPairs(rows, xCS.field, yCS.field, xMap.toCoord, yMap.toCoord),
                 });
             }
         } else {
@@ -72,15 +134,15 @@ export const hcScatterPlotDef: ChartTemplateDef = {
                 name: yCS.field,
                 type: useBubble ? 'bubble' : 'scatter',
                 data: useBubble
-                    ? buildBubblePoints(table, xCS.field, yCS.field, sizeField)
-                    : buildScatterPairs(table, xCS.field, yCS.field),
+                    ? buildBubblePoints(table, xCS.field, yCS.field, sizeField, xMap.toCoord, yMap.toCoord)
+                    : buildScatterPairs(table, xCS.field, yCS.field, xMap.toCoord, yMap.toCoord),
             });
         }
 
         const option: any = {
-            chart: { type: 'scatter' },
-            xAxis: { title: { text: xCS.field } },
-            yAxis: { title: { text: yCS.field } },
+            chart: { type: useBubble ? 'bubble' : 'scatter' },
+            xAxis: xMap.axisConfig,
+            yAxis: yMap.axisConfig,
             legend: colorField
                 ? { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }
                 : { enabled: false },
diff --git a/packages/flint-js/src/highcharts/templates/waterfall.ts b/packages/flint-js/src/highcharts/templates/waterfall.ts
index ae31c56f..3c717061 100644
--- a/packages/flint-js/src/highcharts/templates/waterfall.ts
+++ b/packages/flint-js/src/highcharts/templates/waterfall.ts
@@ -64,12 +64,13 @@ export const hcWaterfallChartDef: ChartTemplateDef = {
         // cumulative), so it is passed through unchanged.
         const data = values.map((v, i) => {
             const t = types[i];
-            const isTotal = t === 'start' || t === 'end';
+            const isClosingTotal = t === 'end';
+            const isTotal = t === 'start' || isClosingTotal;
             const point: any = {
                 y: v,
                 color: isTotal ? TOTAL_COLOR : v >= 0 ? UP_COLOR : DOWN_COLOR,
             };
-            if (isTotal) point.isSum = true;
+            if (isClosingTotal) point.isSum = true;
             return point;
         });
 
@@ -81,11 +82,10 @@ export const hcWaterfallChartDef: ChartTemplateDef = {
             },
             yAxis: {
                 title: { text: valField },
-                min: Math.min(0, ...values),
             },
             legend: { enabled: false },
             tooltip: {
-                pointFormat: '{point.low} → {point.high} ({point.change:+.2f})',
+                pointFormat: '{series.name}: {point.y}',
             },
             plotOptions: {
                 waterfall: {
diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts
index 6257a9ed..97a2ccd2 100644
--- a/packages/flint-js/src/interactive/index.ts
+++ b/packages/flint-js/src/interactive/index.ts
@@ -175,7 +175,7 @@ export function buildInteractiveChart(
     const hoverTolerance = Math.max(0, ...canvasInteractions
         .filter((interaction) => interaction.eventSource.gesture === 'hover')
         .map((interaction) => interaction.eventSource.targetTolerance ?? 0));
-    if (backend !== 'vegalite' && interactions.length > 0) {
+    if (backend !== 'vegalite' && backend !== 'highcharts' && interactions.length > 0) {
         return mountInteractiveChartSurface(
             container,
             input,
@@ -261,7 +261,9 @@ export function buildInteractiveChart(
                 {
                     async mount(chartContainer, chartInput) {
                         const { createHighchartsInteractiveRenderer } = await import('../highcharts/interactive');
-                        return createHighchartsInteractiveRenderer().mount(chartContainer, chartInput);
+                        return createHighchartsInteractiveRenderer({
+                            interactions: canvasInteractions,
+                        }).mount(chartContainer, chartInput);
                     },
                 },
                 { className, ariaLabel, chartId, updates, interactions, warnings },
diff --git a/packages/flint-js/src/interactive/spec/compose.ts b/packages/flint-js/src/interactive/spec/compose.ts
index abb1af6f..1da5045e 100644
--- a/packages/flint-js/src/interactive/spec/compose.ts
+++ b/packages/flint-js/src/interactive/spec/compose.ts
@@ -36,7 +36,7 @@ export function composeInteractiveOptions(input: ComposeInput, options: ComposeO
     const code = options.interactions ?? [];
     const warnings: ChartWarning[] = [];
     let specInteractions = resolved.interactions;
-    if (options.backend !== 'vegalite' && code.length === 0 && specInteractions.length > 0) {
+    if (options.backend !== 'vegalite' && options.backend !== 'highcharts' && code.length === 0 && specInteractions.length > 0) {
         warnings.push({
             severity: 'info',
             code: 'interactions_ignored',
diff --git a/packages/flint-js/src/validate/index.ts b/packages/flint-js/src/validate/index.ts
index ab787424..fa2141d6 100644
--- a/packages/flint-js/src/validate/index.ts
+++ b/packages/flint-js/src/validate/index.ts
@@ -319,7 +319,7 @@ export function validateInteractionSpec(
     assembled: unknown,
 ): ChartWarning[] {
     if (!input.interaction_spec) return [];
-    if (backend === 'vegalite') {
+    if (backend === 'vegalite' || backend === 'highcharts') {
         try {
             const resolved = resolveInteractionSpec(input.interaction_spec);
             const { _interactionSemantics: plan } = assembled as { _interactionSemantics: Parameters[0] };
@@ -329,18 +329,6 @@ export function validateInteractionSpec(
             return [{ severity: 'error', code: 'invalid_interaction_spec', message }];
         }
     }
-    if (backend === 'highcharts') {
-        try {
-            const resolved = resolveInteractionSpec(input.interaction_spec);
-            if (!resolved.interactions.length) {
-                return [{ severity: 'info', code: 'interactions_ignored', message: 'interaction_spec declares no interactions.' }];
-            }
-            return [];
-        } catch (err) {
-            const message = err instanceof Error ? err.message : String(err);
-            return [{ severity: 'error', code: 'invalid_interaction_spec', message }];
-        }
-    }
     return [{
         severity: 'info',
         code: 'interactions_ignored',
diff --git a/packages/flint-js/tests/highcharts-backend.test.ts b/packages/flint-js/tests/highcharts-backend.test.ts
index 67187d37..07151886 100644
--- a/packages/flint-js/tests/highcharts-backend.test.ts
+++ b/packages/flint-js/tests/highcharts-backend.test.ts
@@ -98,8 +98,8 @@ describe('Highcharts backend', () => {
     )) as any;
 
     expect(option.chart.type).toBe('bar');
-    expect(option.yAxis.categories).toEqual(['North', 'East', 'South']);
-    expect(option.xAxis.title.text).toBe('revenue');
+    expect(option.xAxis.categories).toEqual(['North', 'East', 'South']);
+    expect(option.yAxis.title.text).toBe('revenue');
   });
 
   it('assembles a Stacked Bar Chart with one series per color', () => {
@@ -257,7 +257,7 @@ describe('Highcharts backend', () => {
     expect(option.chart.polar).toBe(true);
     expect(option.xAxis.categories).toEqual(['Speed', 'Cost']);
     expect(option.series).toHaveLength(2);
-    expect(option.series[0].type).toBe('line');
+    expect(option.series[0].type).toBe('area');
   });
 
   it('assembles a Heatmap with a colorAxis and [x, y, value] data', () => {
@@ -300,6 +300,7 @@ describe('Highcharts backend', () => {
       { Step: 'Start', Value: 100 },
       { Step: 'Up', Value: 50 },
       { Step: 'Down', Value: -30 },
+      { Step: 'Total', Value: 120 },
     ];
     const option = assembleHighcharts(input(
       'Waterfall Chart',
@@ -309,11 +310,12 @@ describe('Highcharts backend', () => {
     )) as any;
 
     expect(option.series[0].type).toBe('waterfall');
-    expect(option.xAxis.categories).toEqual(['Start', 'Up', 'Down']);
-    expect(option.series[0].data[0].isSum).toBe(true);
+    expect(option.xAxis.categories).toEqual(['Start', 'Up', 'Down', 'Total']);
+    expect(option.series[0].data[0].isSum).toBeFalsy();
     expect(option.series[0].data[0].y).toBe(100);
     expect(option.series[0].data[1].y).toBe(50);
     expect(option.series[0].data[2].y).toBe(-30);
+    expect(option.series[0].data[3].isSum).toBe(true);
   });
 
   it('assembles a Sunburst Chart with a hierarchical tree', () => {
diff --git a/packages/flint-js/tsup.config.ts b/packages/flint-js/tsup.config.ts
index e129aee4..38cafb5f 100644
--- a/packages/flint-js/tsup.config.ts
+++ b/packages/flint-js/tsup.config.ts
@@ -16,6 +16,7 @@ export default defineConfig({
     'vegalite/interactive': 'src/vegalite/interactive.ts',
     'echarts/interactive': 'src/echarts/interactive.ts',
     'chartjs/interactive': 'src/chartjs/interactive.ts',
+    'highcharts/interactive': 'src/highcharts/interactive.ts',
     'plotly/interactive': 'src/plotly/interactive.ts',
     'test-data/index': 'src/test-data/index.ts',
     'gallery/index': 'src/gallery/index.ts',
diff --git a/packages/flint-mcp/src/render/highcharts.ts b/packages/flint-mcp/src/render/highcharts.ts
index 329faa28..904ab01a 100644
--- a/packages/flint-mcp/src/render/highcharts.ts
+++ b/packages/flint-mcp/src/render/highcharts.ts
@@ -1,9 +1,13 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
+import fs from 'node:fs';
+import { createRequire } from 'node:module';
 import { svgToResult } from './svg.js';
 import type { RenderResult, RenderFormat } from './types.js';
 
+const require = createRequire(import.meta.url);
+
 const DEFAULT_W = 400;
 const DEFAULT_H = 320;
 
@@ -15,6 +19,18 @@ export interface HighchartsRenderArgs {
   height: number;
 }
 
+function getScriptContentOrUrl(modulePath: string, cdnUrl: string): { content?: string; url?: string } {
+  try {
+    const resolved = require.resolve(modulePath);
+    if (fs.existsSync(resolved)) {
+      return { content: fs.readFileSync(resolved, 'utf-8') };
+    }
+  } catch {
+    // Fall back to CDN
+  }
+  return { url: cdnUrl };
+}
+
 /**
  * Render a Highcharts options object to an SVG string in a headless browser,
  * then rasterize (for PNG) with resvg. Highcharts has no SSR mode, so this
@@ -26,13 +42,28 @@ export async function renderHighcharts(
   args: HighchartsRenderArgs,
 ): Promise> {
   const puppeteer: any = await import('puppeteer-core');
-  const width = args.width || DEFAULT_W;
-  const height = args.height || DEFAULT_H;
+  const width = args.width || option?._width || DEFAULT_W;
+  const height = args.height || option?._height || DEFAULT_H;
 
-  const browser = await puppeteer.launch({
-    headless: true,
-    args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'],
-  });
+  let browser: any;
+  try {
+    const launchOptions: any = {
+      headless: true,
+      args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'],
+    };
+    if (process.env.PUPPETEER_EXECUTABLE_PATH) {
+      launchOptions.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
+    } else {
+      launchOptions.channel = 'chrome';
+    }
+    browser = await puppeteer.launch(launchOptions);
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err);
+    throw new Error(
+      `Failed to launch Chrome for Highcharts rendering: ${msg}. ` +
+      `Ensure Google Chrome is installed, or set the PUPPETEER_EXECUTABLE_PATH environment variable to a valid Chrome or Chromium executable.`,
+    );
+  }
 
   try {
     const page = await browser.newPage();
@@ -40,24 +71,172 @@ export async function renderHighcharts(
 
     const html = `
 
-
- - -`; - - await page.setContent(html, { waitUntil: 'networkidle0' }); + html, body { margin: 0; padding: 0; overflow: hidden; } +`; + + await page.setContent(html, { waitUntil: 'domcontentloaded' }); + + // Set background via DOM assignment to prevent CSS injection / XSS + await page.evaluate((bg: string) => { + const doc = (globalThis as any).document; + if (doc) { + doc.documentElement.style.backgroundColor = bg; + doc.body.style.backgroundColor = bg; + } + }, args.background); + + // Load Highcharts core and required modules + const modules = [ + { path: 'highcharts/highcharts.js', cdn: 'https://code.highcharts.com/highcharts.js' }, + { path: 'highcharts/highcharts-more.js', cdn: 'https://code.highcharts.com/highcharts-more.js' }, + { path: 'highcharts/modules/heatmap.js', cdn: 'https://code.highcharts.com/modules/heatmap.js' }, + { path: 'highcharts/modules/sunburst.js', cdn: 'https://code.highcharts.com/modules/sunburst.js' }, + ]; + + for (const mod of modules) { + const script = getScriptContentOrUrl(mod.path, mod.cdn); + if (script.content) { + await page.addScriptTag({ content: script.content }); + } else if (script.url) { + await page.addScriptTag({ url: script.url }); + } + } + const svg: string = await page.evaluate( - (opt: any) => (globalThis as any).__renderChart(opt), - { ...option, animation: false }, + (payload: { option: any; width: number; height: number; background: string }) => { + const { option, width, height, background } = payload; + const win = globalThis as any; + const Highcharts = win.Highcharts; + const doc = win.document; + if (!Highcharts) throw new Error('Highcharts failed to load in browser context'); + + function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function disableAnim(opt: any) { + if (!opt || typeof opt !== 'object') return opt; + return { + ...opt, + chart: { ...(opt.chart ?? {}), animation: false }, + plotOptions: { + ...(opt.plotOptions ?? {}), + series: { ...(opt.plotOptions?.series ?? {}), animation: false }, + }, + }; + } + + // Faceted rendering + if (option._facet && Array.isArray(option._facetPanels)) { + const panelRows = option._facetPanels; + const ref = panelRows[0]?.[0]?.option; + const panelW = ref?._width || 400; + const panelH = ref?._height || 300; + const gap = 16; + const hasColHeader = panelRows.some((row: any[]) => row.some((p: any) => p.colVal)); + const hasRowHeader = panelRows.some((row: any[]) => row.some((p: any) => p.rowVal)); + const colHeaderH = hasColHeader ? 22 : 0; + const rowHeaderW = hasRowHeader ? 28 : 0; + const colHeaderPerRow = Boolean(option._facetColHeaderPerRow); + + const titleText = option.title?.text; + const subtitleText = option.subtitle?.text; + const titleOffset = titleText ? (subtitleText ? 44 : 28) : 0; + + let compositeInner = ''; + + if (titleText) { + compositeInner += `${escapeXml(String(titleText))}`; + if (subtitleText) { + compositeInner += `${escapeXml(String(subtitleText))}`; + } + } + + for (let ri = 0; ri < panelRows.length; ri++) { + const rowPanels = panelRows[ri]; + const yHeader = titleOffset + (colHeaderPerRow + ? ri * (colHeaderH + panelH + gap) + : (ri === 0 && hasColHeader ? 0 : colHeaderH)); + const yChart = titleOffset + (colHeaderPerRow + ? ri * (colHeaderH + panelH + gap) + colHeaderH + : colHeaderH + ri * (panelH + gap)); + + for (let ci = 0; ci < rowPanels.length; ci++) { + const panel = rowPanels[ci]; + const xChart = rowHeaderW + ci * (panelW + gap); + + if (panel.colVal && (colHeaderPerRow || ri === 0)) { + const colX = xChart + panelW / 2; + const colY = yHeader + 15; + compositeInner += `${escapeXml(String(panel.colVal))}`; + } + + if (panel.rowVal && ci === 0 && hasRowHeader) { + const rowX = 10; + const rowY = yChart + panelH / 2; + compositeInner += `${escapeXml(String(panel.rowVal))}`; + } + + const container = doc.createElement('div'); + container.style.width = `${panelW}px`; + container.style.height = `${panelH}px`; + doc.body.appendChild(container); + + const panelOpt = disableAnim({ + ...panel.option, + chart: { + ...(panel.option?.chart ?? {}), + width: panelW, + height: panelH, + animation: false, + }, + }); + + const chart = Highcharts.chart(container, panelOpt); + const svgEl = container.querySelector('svg'); + if (svgEl) { + svgEl.setAttribute('x', String(xChart)); + svgEl.setAttribute('y', String(yChart)); + compositeInner += svgEl.outerHTML; + } + chart.destroy(); + container.remove(); + } + } + + const bgRect = background ? `` : ''; + return `${bgRect}${compositeInner}`; + } + + // Single chart + const container = doc.createElement('div'); + container.style.width = `${width}px`; + container.style.height = `${height}px`; + doc.body.appendChild(container); + + const chartOpt = disableAnim({ + ...option, + chart: { + ...(option?.chart ?? {}), + width, + height, + animation: false, + }, + }); + + const chart = Highcharts.chart(container, chartOpt); + const svgEl = container.querySelector('svg'); + const resSvg = svgEl ? svgEl.outerHTML : ''; + chart.destroy(); + container.remove(); + return resSvg; + }, + { option, width, height, background: args.background }, ); return svgToResult(svg, { diff --git a/packages/flint-mcp/src/render/index.ts b/packages/flint-mcp/src/render/index.ts index eeee253e..5d99b2d0 100644 --- a/packages/flint-mcp/src/render/index.ts +++ b/packages/flint-mcp/src/render/index.ts @@ -81,7 +81,19 @@ export async function renderChart( // metadata and are safe to remove for every backend. const w = width ?? spec?.width ?? undefined; const h = height ?? spec?.height ?? undefined; + const isFacet = spec?._facet; + const facetPanels = spec?._facetPanels; + const facetRows = spec?._facetRows; + const facetCols = spec?._facetCols; + const facetColHeaderPerRow = spec?._facetColHeaderPerRow; stripPrivateKeys(spec); + if (backend === 'highcharts' && (isFacet || facetPanels)) { + spec._facet = true; + spec._facetPanels = facetPanels; + spec._facetRows = facetRows; + spec._facetCols = facetCols; + spec._facetColHeaderPerRow = facetColHeaderPerRow; + } let artifact: Omit; switch (backend) { diff --git a/packages/flint-mcp/tests/render.test.ts b/packages/flint-mcp/tests/render.test.ts index c30d5384..c4c02125 100644 --- a/packages/flint-mcp/tests/render.test.ts +++ b/packages/flint-mcp/tests/render.test.ts @@ -34,7 +34,7 @@ function pngDimensions(buffer: Buffer): { width: number; height: number } { } describe('renderChart → PNG', () => { - for (const backend of ['vegalite', 'echarts', 'chartjs'] as const) { + for (const backend of ['vegalite', 'echarts', 'chartjs', 'highcharts'] as const) { it(`renders a ${backend} bar chart to a non-trivial PNG`, async () => { const res = await renderChart(sales, backend, { format: 'png' }); expect(res.backend).toBe(backend); @@ -47,10 +47,29 @@ describe('renderChart → PNG', () => { expect(Array.isArray(res.warnings)).toBe(true); }); } + + it('renders a faceted highcharts chart to a composite PNG', async () => { + const facetedSales: ChartAssemblyInput = { + ...sales, + chart_spec: { + ...sales.chart_spec, + encodings: { + x: { field: 'region' }, + y: { field: 'revenue' }, + column: { field: 'region' }, + }, + }, + }; + const res = await renderChart(facetedSales, 'highcharts', { format: 'png' }); + expect(res.backend).toBe('highcharts'); + expect(res.mimeType).toBe('image/png'); + expect(res.buffer).toBeInstanceOf(Buffer); + expect(res.buffer!.length).toBeGreaterThan(1000); + }); }); describe('renderChart → SVG', () => { - for (const backend of ['vegalite', 'echarts'] as const) { + for (const backend of ['vegalite', 'echarts', 'highcharts'] as const) { it(`renders a ${backend} bar chart to SVG markup`, async () => { const res = await renderChart(sales, backend, { format: 'svg' }); expect(res.mimeType).toBe('image/svg+xml'); diff --git a/site/src/components/HighchartsView.tsx b/site/src/components/HighchartsView.tsx index e93d24db..8afd41d3 100644 --- a/site/src/components/HighchartsView.tsx +++ b/site/src/components/HighchartsView.tsx @@ -83,10 +83,9 @@ function HighchartsSingleView({ }, [option, designedWidth, renderHeight]); useEffect(() => { - const chart = chartRef.current; return () => { try { - chart?.destroy(); + chartRef.current?.destroy(); } catch { // A chart that failed to initialize can throw on destroy; unmount // must still complete. @@ -95,23 +94,23 @@ function HighchartsSingleView({ }; }, []); - if (error) { - return ( -
-        {error}
-      
- ); - } - return ( -
+ <> + {error && ( +
+          {error}
+        
+ )} +
+ ); } diff --git a/site/src/components/TripleChart.tsx b/site/src/components/TripleChart.tsx index 85bfb5db..08879d5a 100644 --- a/site/src/components/TripleChart.tsx +++ b/site/src/components/TripleChart.tsx @@ -5,6 +5,7 @@ import { InteractiveVegaLiteView, hasInteractionEntries } from './InteractiveVeg import { EChartsView } from './EChartsView'; import { ChartjsView } from './ChartjsView'; import { PlotlyView } from './PlotlyView'; +import { HighchartsView } from './HighchartsView'; import { testCaseToAssemblyInput } from '../shared/test-case-utils'; import { BACKENDS, @@ -135,6 +136,7 @@ export function TripleChart({ {backend === 'echarts' && } {backend === 'chartjs' && } {backend === 'plotly' && } + {backend === 'highcharts' && } ) : (
diff --git a/site/src/components/WallChart.tsx b/site/src/components/WallChart.tsx
index 4d084630..19912d34 100644
--- a/site/src/components/WallChart.tsx
+++ b/site/src/components/WallChart.tsx
@@ -5,6 +5,7 @@ import { VegaLiteView } from './VegaLiteView';
 import { EChartsView } from './EChartsView';
 import { ChartjsView } from './ChartjsView';
 import { PlotlyView } from './PlotlyView';
+import { HighchartsView } from './HighchartsView';
 import { testCaseToAssemblyInput, thumbnailCanvasSize, withHouse, type CanvasSize } from '../shared/test-case-utils';
 import { BACKENDS, type PreviewBackend } from '../shared/supported-backends';
 import { siteTheme } from '../shared/theme';
@@ -94,5 +95,6 @@ export function WallChart({
   if (backend === 'vegalite') return ;
   if (backend === 'echarts') return ;
   if (backend === 'plotly') return ;
+  if (backend === 'highcharts') return ;
   return ;
 }
diff --git a/site/src/routes/Landing.tsx b/site/src/routes/Landing.tsx
index 7a720f24..a10b2937 100644
--- a/site/src/routes/Landing.tsx
+++ b/site/src/routes/Landing.tsx
@@ -37,12 +37,16 @@ export function Landing() {
     let pending: string | null = null;
     try {
       pending = sessionStorage.getItem(LANDING_SCROLL_TO_KEY);
-      if (pending) sessionStorage.removeItem(LANDING_SCROLL_TO_KEY);
     } catch {
       // Privacy modes may block storage.
     }
     if (pending !== WECHAT_SECTION_ID) return;
     const timer = window.setTimeout(() => {
+      try {
+        sessionStorage.removeItem(LANDING_SCROLL_TO_KEY);
+      } catch {
+        // Privacy modes may block storage.
+      }
       scrollToHeading(WECHAT_SECTION_ID);
     }, 50);
     return () => window.clearTimeout(timer);

From c34ab8694522fab74ca34bce73484f9ace5e9eaa Mon Sep 17 00:00:00 2001
From: Sam 
Date: Tue, 20 Oct 2026 18:25:35 +0530
Subject: [PATCH 3/4] fix(highcharts): address Copilot review findings for
 faceting, interactions, and templates

---
 .../skills/flint-chart-author/SKILL.md        | 10 ++-
 packages/flint-js/src/highcharts/assemble.ts  | 26 ++++++-
 .../src/highcharts/instantiate-spec.ts        | 63 +++++++++------
 .../flint-js/src/highcharts/interactive.ts    | 38 +++++++--
 .../flint-js/src/highcharts/templates/line.ts | 78 ++++++++++++++++---
 .../flint-js/src/highcharts/templates/pie.ts  |  1 +
 .../src/highcharts/templates/scatter.ts       | 23 +++++-
 .../src/highcharts/templates/sunburst.ts      | 73 ++++++++++-------
 packages/flint-mcp/src/render/highcharts.ts   |  2 +
 site/src/shared/chart-categories.ts           | 43 ++++++++++
 10 files changed, 283 insertions(+), 74 deletions(-)

diff --git a/.github/extensions/flint-chart/skills/flint-chart-author/SKILL.md b/.github/extensions/flint-chart/skills/flint-chart-author/SKILL.md
index 9894d0f1..ceac6f98 100644
--- a/.github/extensions/flint-chart/skills/flint-chart-author/SKILL.md
+++ b/.github/extensions/flint-chart/skills/flint-chart-author/SKILL.md
@@ -10,7 +10,7 @@ description: "Use when: the user asks to make or render charts with flint-chart,
 Your output is the **spec**: the `chart_spec` and `semantic_types` of a
 `ChartAssemblyInput`. You reference data columns **by name**. The host
 passes the resulting input to `assembleVegaLite`, `assembleECharts`,
-or `assembleChartjs` to get a backend spec.
+`assembleChartjs`, or `assembleHighcharts` to get a backend spec.
 
 **You write the input spec, not the output spec.** And critically:
 
@@ -78,16 +78,18 @@ npm install flint-chart
 npm install vega vega-lite vega-embed  # browser Vega-Lite rendering
 npm install echarts                    # ECharts rendering
 npm install chart.js                   # Chart.js rendering
+npm install highcharts                 # Highcharts rendering
 ```
 
 Then compile with the requested backend:
 
 ```ts
-import { assembleChartjs, assembleECharts, assembleVegaLite } from 'flint-chart';
+import { assembleChartjs, assembleECharts, assembleHighcharts, assembleVegaLite } from 'flint-chart';
 
 const vegaLiteSpec = assembleVegaLite(input);
 const echartsOption = assembleECharts(input);
 const chartjsConfig = assembleChartjs(input);
+const highchartsOption = assembleHighcharts(input);
 ```
 
 Python support is planned for a later release. Until the PyPI package is
@@ -134,7 +136,7 @@ Use the binding mode that matches the runtime. Do not mix them.
 3. **Generated application or notebook code: bind runtime variables.** If the
   user asks you to add Flint to code, write normal data-loading code first and
   pass a real runtime value, e.g. `data: { values: rows }`, to
-  `assembleVegaLite`, `assembleECharts`, or `assembleChartjs`. This variable
+  `assembleVegaLite`, `assembleECharts`, `assembleChartjs`, or `assembleHighcharts`. This variable
   pattern is for generated code, not for MCP tool calls.
   - Parse CSV with a real parser (`d3.csvParse`, Papa Parse, `pandas`), never
     `line.split(',')`: quoted values such as `"Korea, Rep."` shift every later
@@ -415,6 +417,8 @@ support a subset (verify if targeting a non-VL backend):
 - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar,
   Lollipop, Bump, Combo, Line, Area, Range Area, Pie, Doughnut, Histogram,
   Radar, Rose, Slope, Connected Scatter.
+- **Highcharts** supports: Scatter Plot, Bar, Grouped Bar, Stacked Bar,
+  Line, Area, Pie, Doughnut, Histogram, Radar, Heatmap, Waterfall, Sunburst.
 
 You do not need to call the library or inspect its source to author the
 input — pick from this table.
diff --git a/packages/flint-js/src/highcharts/assemble.ts b/packages/flint-js/src/highcharts/assemble.ts
index 222be701..47bc91e8 100644
--- a/packages/flint-js/src/highcharts/assemble.ts
+++ b/packages/flint-js/src/highcharts/assemble.ts
@@ -313,6 +313,11 @@ export function assembleHighcharts(input: ChartAssemblyInput): any {
             }
         }
 
+        const colorField = channelSemantics.color?.field;
+        const globalColorCategories = (colorField && channelSemantics.color?.type !== 'quantitative')
+            ? [...new Set(values.map((r: any) => String(r[colorField])))]
+            : undefined;
+
         const panelRows: any[][] = [];
         for (const cells of gridRows) {
             const rowPanels: any[] = [];
@@ -333,6 +338,17 @@ export function assembleHighcharts(input: ChartAssemblyInput): any {
                 hcApplyLayoutToSpec(panelOption, panelContext, []);
                 if (addTooltipsOpt) hcApplyTooltips(panelOption);
                 if (chartTemplate.postProcess) chartTemplate.postProcess(panelOption, panelContext);
+
+                // Align series colors to global categories so every panel uses matching colors
+                if (globalColorCategories && Array.isArray(panelOption.series) && Array.isArray(panelOption.colors)) {
+                    for (const s of panelOption.series) {
+                        const idx = globalColorCategories.indexOf(s.name);
+                        if (idx >= 0) {
+                            s.color = panelOption.colors[idx % panelOption.colors.length];
+                        }
+                    }
+                }
+
                 rowPanels.push({
                     colVal,
                     rowVal,
@@ -347,6 +363,8 @@ export function assembleHighcharts(input: ChartAssemblyInput): any {
             !!colField,
             !!rowField,
             wrapColumnOnly,
+            input.chart_spec.title !== undefined,
+            input.chart_spec.subtitle !== undefined,
         );
     } else {
         hcOption = structuredClone(chartTemplate.template);
@@ -393,6 +411,9 @@ export function assembleHighcharts(input: ChartAssemblyInput): any {
         if (channelSemantics.y?.type === 'quantitative' || channelSemantics.y?.type === 'temporal') navAxes.push('y');
     }
     const capabilities: import('../core/interaction-spec').InteractionCapability[] = [];
+    if (!hasFacet) {
+        capabilities.push('elements');
+    }
     if (hasLegend) capabilities.push('legend');
     if (navAxes.length > 0) capabilities.push('navigation');
 
@@ -429,6 +450,8 @@ function hcCombineFacetPanels(
     hasColHeader: boolean,
     hasRowHeader: boolean,
     colHeaderPerRow = false,
+    hasTitle = false,
+    hasSubtitle = false,
 ): any {
     const rows = panelRows.length;
     const cols = Math.max(1, ...panelRows.map(r => r.length));
@@ -438,6 +461,7 @@ function hcCombineFacetPanels(
     const gap = 16;
     const colHeaderH = hasColHeader ? 22 : 0;
     const rowHeaderW = hasRowHeader ? 28 : 0;
+    const titleH = (hasTitle ? 28 : 0) + (hasSubtitle ? 16 : 0);
 
     const headerBands = colHeaderPerRow ? rows : (hasColHeader ? 1 : 0);
 
@@ -448,6 +472,6 @@ function hcCombineFacetPanels(
         _facetCols: cols,
         _facetColHeaderPerRow: colHeaderPerRow,
         _width: rowHeaderW + cols * panelW + (cols - 1) * gap,
-        _height: headerBands * colHeaderH + rows * panelH + (rows - 1) * gap,
+        _height: titleH + headerBands * colHeaderH + rows * panelH + (rows - 1) * gap,
     };
 }
diff --git a/packages/flint-js/src/highcharts/instantiate-spec.ts b/packages/flint-js/src/highcharts/instantiate-spec.ts
index 6bd352e4..55b0b581 100644
--- a/packages/flint-js/src/highcharts/instantiate-spec.ts
+++ b/packages/flint-js/src/highcharts/instantiate-spec.ts
@@ -50,35 +50,41 @@ export function hcApplyLayoutToSpec(
 ): void {
     const { channelSemantics, layout, canvasSize } = context;
 
+    // ── Canvas dimensions ────────────────────────────────────────────────
     // ── Canvas dimensions ────────────────────────────────────────────────
     // Highcharts sizes to the chart container; the site renderer uses a
-    // wrapper div at the designed size. Only compute for axis-based charts.
-    const hasAxes = !!(option.xAxis || option.yAxis) && option.chart?.type !== 'pie';
-    if (hasAxes && !option._width) {
-        const PADDING = 80;
-        const xIsDiscrete = layout.xNominalCount > 0 || layout.xContinuousAsDiscrete > 0;
-        const yIsDiscrete = layout.yNominalCount > 0 || layout.yContinuousAsDiscrete > 0;
-
-        let plotWidth: number;
-        let plotHeight: number;
-
-        if (xIsDiscrete && layout.xStepUnit !== 'group') {
-            const xItemCount = layout.xNominalCount || layout.xContinuousAsDiscrete || 0;
-            plotWidth = xItemCount > 0 ? layout.xStep * xItemCount : (layout.subplotWidth || canvasSize.width);
-        } else {
-            plotWidth = layout.subplotWidth || canvasSize.width;
-        }
+    // wrapper div at the designed size.
+    const hasAxes = !!(option.xAxis || option.yAxis) && option.chart?.type !== 'pie' && option.chart?.type !== 'sunburst';
+    if (!option._width) {
+        if (hasAxes) {
+            const PADDING = 80;
+            const xIsDiscrete = layout.xNominalCount > 0 || layout.xContinuousAsDiscrete > 0;
+            const yIsDiscrete = layout.yNominalCount > 0 || layout.yContinuousAsDiscrete > 0;
+
+            let plotWidth: number;
+            let plotHeight: number;
+
+            if (xIsDiscrete && layout.xStepUnit !== 'group') {
+                const xItemCount = layout.xNominalCount || layout.xContinuousAsDiscrete || 0;
+                plotWidth = xItemCount > 0 ? layout.xStep * xItemCount : (layout.subplotWidth || canvasSize.width);
+            } else {
+                plotWidth = layout.subplotWidth || canvasSize.width;
+            }
 
-        if (yIsDiscrete && layout.yStepUnit !== 'group') {
-            const yItemCount = layout.yNominalCount || layout.yContinuousAsDiscrete || 0;
-            plotHeight = yItemCount > 0 ? layout.yStep * yItemCount : (layout.subplotHeight || canvasSize.height);
+            if (yIsDiscrete && layout.yStepUnit !== 'group') {
+                const yItemCount = layout.yNominalCount || layout.yContinuousAsDiscrete || 0;
+                plotHeight = yItemCount > 0 ? layout.yStep * yItemCount : (layout.subplotHeight || canvasSize.height);
+            } else {
+                plotHeight = layout.subplotHeight || canvasSize.height;
+            }
+
+            const legendGutter = option.legend && option.legend.enabled !== false ? 96 : 0;
+            option._width = plotWidth + PADDING + legendGutter;
+            option._height = plotHeight + PADDING;
         } else {
-            plotHeight = layout.subplotHeight || canvasSize.height;
+            option._width = layout.subplotWidth || canvasSize.width;
+            option._height = layout.subplotHeight || canvasSize.height;
         }
-
-        const legendGutter = option.legend && option.legend.enabled !== false ? 96 : 0;
-        option._width = plotWidth + PADDING + legendGutter;
-        option._height = plotHeight + PADDING;
         option.chart = option.chart || {};
         option.chart.width = option._width;
         option.chart.height = option._height;
@@ -96,6 +102,8 @@ export function hcApplyLayoutToSpec(
         // Highcharts category axes handle zero via the numeric y/x axis; only
         // pin soft zero bounds when the zero decision says to include zero and the axis
         // is numeric (not category/datetime).
+        const isCategoryAxis = axisObj.type === 'category' || Array.isArray(axisObj.categories) || cs.type === 'nominal' || cs.type === 'ordinal';
+        if (isCategoryAxis) continue;
         const isNumericAxis = !axisObj.type || axisObj.type === 'linear' || axisObj.type === 'value' || axisObj.type === 'datetime' || axisObj.type === 'logarithmic';
         if (!isNumericAxis) continue;
         if (decision.zero) {
@@ -167,6 +175,13 @@ export function hcApplyLayoutToSpec(
     }
     if (effectivePalette && effectivePalette.length > 0) {
         option.colors = [...effectivePalette];
+        if (option.colorAxis) {
+            if (effectivePalette.length >= 2) {
+                option.colorAxis.minColor = effectivePalette[0];
+                option.colorAxis.maxColor = effectivePalette[effectivePalette.length - 1];
+                option.colorAxis.stops = effectivePalette.map((c, i) => [i / (effectivePalette!.length - 1), c]);
+            }
+        }
     }
 
     // ── Credits (keep output clean; hosts can re-enable) ────────────────────
diff --git a/packages/flint-js/src/highcharts/interactive.ts b/packages/flint-js/src/highcharts/interactive.ts
index 6c2e7245..827153d6 100644
--- a/packages/flint-js/src/highcharts/interactive.ts
+++ b/packages/flint-js/src/highcharts/interactive.ts
@@ -67,6 +67,13 @@ export function createHighchartsInteractiveRenderer(rendererOptions?: Highcharts
             let latestStarts: ViewportState = {};
             let lastOptionJson = '';
 
+            const highlightDef = rendererOptions?.interactions?.find(
+                (it: any) => it.id === 'click-highlight' || it.eventSource?.type === 'click' || it.affordances?.selection,
+            );
+            const dimOpacity = typeof highlightDef?.options?.dimOpacity === 'number'
+                ? highlightDef.options.dimOpacity
+                : DIM_OPACITY;
+
             const applyVisualStates = (): void => {
                 for (const chart of currentCharts) {
                     chart.series.forEach((series, seriesIndex) => {
@@ -74,7 +81,7 @@ export function createHighchartsInteractiveRenderer(rendererOptions?: Highcharts
                             graphic?: { attr: (attributes: Record) => void };
                         }>;
                         const seriesOpacity = activeSelection
-                            ? (seriesIndex === activeSelection.series ? 1 : DIM_OPACITY)
+                            ? (seriesIndex === activeSelection.series ? 1 : dimOpacity)
                             : 1;
                         points.forEach((point) => {
                             if (point.graphic && typeof point.graphic.attr === 'function') {
@@ -115,13 +122,20 @@ export function createHighchartsInteractiveRenderer(rendererOptions?: Highcharts
 
                 const decorateOption = (panelOpt: any) => {
                     const plotOptions = panelOpt?.plotOptions ?? {};
-                    const isZoomable = rendererOptions?.interactions?.some((it: any) => it.eventSource?.type === 'navigation');
+                    const navInteraction = rendererOptions?.interactions?.find(
+                        (it: any) => it.eventSource?.type === 'navigation' || it.id === 'navigate',
+                    );
+                    const navAxes = navInteraction?.options?.axes ?? 'x';
+                    const allowZoom = navInteraction?.options?.zoom !== false;
+                    const zoomType = (navInteraction && allowZoom)
+                        ? (navAxes === 'xy' ? 'xy' : navAxes === 'y' ? 'y' : 'x')
+                        : undefined;
                     return {
                         ...panelOpt,
                         chart: {
                             ...(panelOpt?.chart ?? {}),
                             animation: false,
-                            ...(isZoomable ? { zoomType: 'x' } : {}),
+                            ...(zoomType ? { zoomType, zooming: { type: zoomType } } : {}),
                         },
                         plotOptions: {
                             ...plotOptions,
@@ -317,12 +331,26 @@ interface ViewportChange {
     value: { x?: { min: number; max: number }; y?: { min: number; max: number } };
 }
 
+function parseAxisInterval(val: unknown): { min: number; max: number } | undefined {
+    if (!val) return undefined;
+    if (Array.isArray(val) && val.length >= 2) {
+        return { min: Number(val[0]), max: Number(val[1]) };
+    }
+    if (typeof val === 'object' && 'min' in val && 'max' in val) {
+        return { min: Number((val as any).min), max: Number((val as any).max) };
+    }
+    return undefined;
+}
+
 function extractViewportChange(update: { ops: readonly unknown[] }): ViewportChange | null {
     for (const op of update.ops as Array<{ op: string; axes?: 'x' | 'y' | 'xy'; value?: unknown }>) {
-        if (op.op === 'set-viewport' && op.value) {
+        if (op.op === 'set-viewport' && op.value && typeof op.value === 'object') {
+            const rawVal = op.value as any;
+            const x = parseAxisInterval(rawVal.x);
+            const y = parseAxisInterval(rawVal.y);
             return {
                 axes: op.axes ?? 'xy',
-                value: op.value as ViewportChange['value'],
+                value: { x, y },
             };
         }
     }
diff --git a/packages/flint-js/src/highcharts/templates/line.ts b/packages/flint-js/src/highcharts/templates/line.ts
index aac47376..88abab50 100644
--- a/packages/flint-js/src/highcharts/templates/line.ts
+++ b/packages/flint-js/src/highcharts/templates/line.ts
@@ -61,14 +61,27 @@ function buildLineXAxis(
 }
 
 /** Build [x, y] pairs for continuous axes, sorted by x. */
-function buildPairs(table: any[], xField: string, yField: string, temporal: boolean): [number, number][] {
+function buildPairs(
+    table: any[],
+    xField: string,
+    yField: string,
+    temporal: boolean,
+    yCategories?: string[],
+): [number, number][] {
     const pairs: [number, number][] = [];
     for (const row of table) {
         const x = row[xField];
         const y = row[yField];
         if (x == null || y == null) continue;
         const xv = temporal ? +new Date(x) : Number(x);
-        const yv = Number(y);
+        let yv: number;
+        if (yCategories && yCategories.length > 0) {
+            const idx = yCategories.indexOf(String(y));
+            if (idx < 0) continue;
+            yv = idx;
+        } else {
+            yv = Number(y);
+        }
         if (!Number.isFinite(xv) || !Number.isFinite(yv)) continue;
         pairs.push([xv, yv]);
     }
@@ -76,6 +89,28 @@ function buildPairs(table: any[], xField: string, yField: string, temporal: bool
     return pairs;
 }
 
+function buildLineCategoryValues(
+    rows: any[],
+    xField: string,
+    yField: string,
+    categories: string[],
+    yCategories?: string[],
+): (number | null)[] {
+    if (!yCategories || yCategories.length === 0) {
+        return buildCategoryValues(rows, xField, yField, categories);
+    }
+    const map = new Map();
+    for (const row of rows) {
+        const cat = String(row[xField] ?? '');
+        const val = row[yField];
+        if (val != null) {
+            const idx = yCategories.indexOf(String(val));
+            if (idx >= 0) map.set(cat, idx);
+        }
+    }
+    return categories.map(cat => map.get(cat) ?? null);
+}
+
 function buildLineSeries(
     table: any[],
     ctx: any,
@@ -83,6 +118,7 @@ function buildLineSeries(
     yField: string,
     colorField: string | undefined,
     sizeField: string | undefined,
+    yCategories?: string[],
 ): any[] {
     const xField = xCS.field;
     const { dataMode } = buildLineXAxis(ctx, xCS);
@@ -98,8 +134,8 @@ function buildLineSeries(
             series.push({
                 name,
                 data: dataMode === 'category'
-                    ? buildCategoryValues(rows, xField, yField, allCategories)
-                    : buildPairs(rows, xField, yField, xCS.type === 'temporal'),
+                    ? buildLineCategoryValues(rows, xField, yField, allCategories, yCategories)
+                    : buildPairs(rows, xField, yField, xCS.type === 'temporal', yCategories),
                 ...(sizeField ? { marker: { radius: 5 } } : {}),
             });
         }
@@ -109,8 +145,8 @@ function buildLineSeries(
     series.push({
         name: yField,
         data: dataMode === 'category'
-            ? buildCategoryValues(table, xField, yField, allCategories)
-            : buildPairs(table, xField, yField, xCS.type === 'temporal'),
+            ? buildLineCategoryValues(table, xField, yField, allCategories, yCategories)
+            : buildPairs(table, xField, yField, xCS.type === 'temporal', yCategories),
         ...(sizeField ? { marker: { radius: 5 } } : {}),
     });
     return series;
@@ -135,14 +171,23 @@ export const hcLineChartDef: ChartTemplateDef = {
 
         if (!xCS?.field || !yCS?.field) return;
 
+        const isYCategory = yCS.type === 'nominal' || yCS.type === 'ordinal';
+        const yCategories = isYCategory ? extractCategories(table, yCS.field, yCS.ordinalSortOrder) : undefined;
+        const isDiscreteColor = colorField && channelSemantics.color?.type !== 'quantitative';
+
         const { xAxis } = buildLineXAxis(ctx, xCS);
-        const series = buildLineSeries(table, ctx, xCS, yCS.field, colorField, sizeField);
+        const series = buildLineSeries(table, ctx, xCS, yCS.field, isDiscreteColor ? colorField : undefined, sizeField, yCategories);
+
+        const yAxis: any = { title: { text: yCS.field } };
+        if (isYCategory && yCategories) {
+            yAxis.categories = yCategories;
+        }
 
         const option: any = {
             chart: { type: 'line' },
             xAxis,
-            yAxis: { title: { text: yCS.field } },
-            legend: colorField
+            yAxis,
+            legend: isDiscreteColor
                 ? { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }
                 : { enabled: false },
             plotOptions: {
@@ -195,8 +240,17 @@ export const hcAreaChartDef: ChartTemplateDef = {
 
         if (!xCS?.field || !yCS?.field) return;
 
+        const isYCategory = yCS.type === 'nominal' || yCS.type === 'ordinal';
+        const yCategories = isYCategory ? extractCategories(table, yCS.field, yCS.ordinalSortOrder) : undefined;
+        const isDiscreteColor = colorField && channelSemantics.color?.type !== 'quantitative';
+
         const { xAxis } = buildLineXAxis(ctx, xCS);
-        const series = buildLineSeries(table, ctx, xCS, yCS.field, colorField, undefined);
+        const series = buildLineSeries(table, ctx, xCS, yCS.field, isDiscreteColor ? colorField : undefined, undefined, yCategories);
+
+        const yAxis: any = { title: { text: yCS.field } };
+        if (isYCategory && yCategories) {
+            yAxis.categories = yCategories;
+        }
 
         const isSmooth = chartProperties?.interpolate === 'monotone';
         const chartType = isSmooth ? 'areaspline' : 'area';
@@ -204,8 +258,8 @@ export const hcAreaChartDef: ChartTemplateDef = {
         const option: any = {
             chart: { type: chartType },
             xAxis,
-            yAxis: { title: { text: yCS.field } },
-            legend: colorField
+            yAxis,
+            legend: isDiscreteColor
                 ? { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }
                 : { enabled: false },
             plotOptions: {
diff --git a/packages/flint-js/src/highcharts/templates/pie.ts b/packages/flint-js/src/highcharts/templates/pie.ts
index c52dba08..09467473 100644
--- a/packages/flint-js/src/highcharts/templates/pie.ts
+++ b/packages/flint-js/src/highcharts/templates/pie.ts
@@ -104,6 +104,7 @@ function buildPieOption(
             pie: {
                 allowPointSelect: true,
                 cursor: 'pointer',
+                showInLegend: !!colorField,
                 ...(isDoughnut ? { innerSize: `${innerRadius}%` } : {}),
                 dataLabels: {
                     enabled: true,
diff --git a/packages/flint-js/src/highcharts/templates/scatter.ts b/packages/flint-js/src/highcharts/templates/scatter.ts
index 62055803..6dc69016 100644
--- a/packages/flint-js/src/highcharts/templates/scatter.ts
+++ b/packages/flint-js/src/highcharts/templates/scatter.ts
@@ -118,7 +118,9 @@ export const hcScatterPlotDef: ChartTemplateDef = {
         const useBubble = !!sizeField;
         const series: any[] = [];
 
-        if (colorField) {
+        const isContinuousColor = colorField && channelSemantics.color?.type === 'quantitative';
+
+        if (colorField && !isContinuousColor) {
             const groups = groupBy(table, colorField);
             for (const [name, rows] of groups) {
                 series.push({
@@ -129,6 +131,24 @@ export const hcScatterPlotDef: ChartTemplateDef = {
                         : buildScatterPairs(rows, xCS.field, yCS.field, xMap.toCoord, yMap.toCoord),
                 });
             }
+        } else if (isContinuousColor) {
+            const points = table.map((row) => {
+                const x = xMap.toCoord(row[xCS.field!]);
+                const y = yMap.toCoord(row[yCS.field!]);
+                const val = Number(row[colorField!]);
+                if (x === null || y === null) return null;
+                return {
+                    x,
+                    y,
+                    ...(useBubble ? { z: Number(row[sizeField!]) || 1 } : {}),
+                    colorValue: Number.isFinite(val) ? val : undefined,
+                };
+            }).filter(Boolean);
+            series.push({
+                name: yCS.field,
+                type: useBubble ? 'bubble' : 'scatter',
+                data: points,
+            });
         } else {
             series.push({
                 name: yCS.field,
@@ -143,6 +163,7 @@ export const hcScatterPlotDef: ChartTemplateDef = {
             chart: { type: useBubble ? 'bubble' : 'scatter' },
             xAxis: xMap.axisConfig,
             yAxis: yMap.axisConfig,
+            ...(isContinuousColor ? { colorAxis: {} } : {}),
             legend: colorField
                 ? { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' }
                 : { enabled: false },
diff --git a/packages/flint-js/src/highcharts/templates/sunburst.ts b/packages/flint-js/src/highcharts/templates/sunburst.ts
index 1536f8cf..e180caf6 100644
--- a/packages/flint-js/src/highcharts/templates/sunburst.ts
+++ b/packages/flint-js/src/highcharts/templates/sunburst.ts
@@ -34,37 +34,54 @@ export const hcSunburstChartDef: ChartTemplateDef = {
 
         const branches = extractCategories(table, colorField, channelSemantics.color?.ordinalSortOrder);
 
-        // Aggregate leaf values per (branch, leaf).
-        const leafValues = new Map();
-        const leafNames = new Map();
-        for (const row of table) {
-            const branch = String(row[colorField] ?? '');
-            const leaf = groupField ? String(row[groupField] ?? '') : '';
-            if (!branch || !leaf) continue;
-            const key = `${branch}·${leaf}`;
-            const v = sizeField ? Number(row[sizeField]) : 1;
-            leafValues.set(key, (leafValues.get(key) ?? 0) + (Number.isFinite(v) ? v : 1));
-            if (!leafNames.has(key)) leafNames.set(key, leaf);
-        }
-
         const data: any[] = [];
-        for (const branch of branches) {
-            const branchTotal = [...leafValues.entries()]
-                .filter(([key]) => key.startsWith(`${branch}·`))
-                .reduce((sum, [, v]) => sum + v, 0);
-            data.push({
-                id: branch,
-                name: branch,
-                value: branchTotal > 0 ? branchTotal : undefined,
-            });
-            for (const [key, value] of leafValues) {
-                if (!key.startsWith(`${branch}·`)) continue;
+        if (!groupField) {
+            const branchValues = new Map();
+            for (const row of table) {
+                const branch = String(row[colorField] ?? '');
+                if (!branch) continue;
+                const v = sizeField ? Number(row[sizeField]) : 1;
+                branchValues.set(branch, (branchValues.get(branch) ?? 0) + (Number.isFinite(v) ? v : 1));
+            }
+            for (const branch of branches) {
+                data.push({
+                    id: branch,
+                    name: branch,
+                    value: branchValues.get(branch) ?? 0,
+                });
+            }
+        } else {
+            // Aggregate leaf values per (branch, leaf).
+            const leafValues = new Map();
+            const leafNames = new Map();
+            for (const row of table) {
+                const branch = String(row[colorField] ?? '');
+                const leaf = String(row[groupField] ?? '');
+                if (!branch || !leaf) continue;
+                const key = `${branch}·${leaf}`;
+                const v = sizeField ? Number(row[sizeField]) : 1;
+                leafValues.set(key, (leafValues.get(key) ?? 0) + (Number.isFinite(v) ? v : 1));
+                if (!leafNames.has(key)) leafNames.set(key, leaf);
+            }
+
+            for (const branch of branches) {
+                const branchTotal = [...leafValues.entries()]
+                    .filter(([key]) => key.startsWith(`${branch}·`))
+                    .reduce((sum, [, v]) => sum + v, 0);
                 data.push({
-                    id: key,
-                    parent: branch,
-                    name: leafNames.get(key) ?? key.slice(branch.length + 1),
-                    value,
+                    id: branch,
+                    name: branch,
+                    value: branchTotal > 0 ? branchTotal : undefined,
                 });
+                for (const [key, value] of leafValues) {
+                    if (!key.startsWith(`${branch}·`)) continue;
+                    data.push({
+                        id: key,
+                        parent: branch,
+                        name: leafNames.get(key) ?? key.slice(branch.length + 1),
+                        value,
+                    });
+                }
             }
         }
 
diff --git a/packages/flint-mcp/src/render/highcharts.ts b/packages/flint-mcp/src/render/highcharts.ts
index 904ab01a..a1e0d818 100644
--- a/packages/flint-mcp/src/render/highcharts.ts
+++ b/packages/flint-mcp/src/render/highcharts.ts
@@ -193,6 +193,7 @@ export async function renderHighcharts(
                   ...(panel.option?.chart ?? {}),
                   width: panelW,
                   height: panelH,
+                  backgroundColor: background || (panel.option?.chart?.backgroundColor ?? 'transparent'),
                   animation: false,
                 },
               });
@@ -225,6 +226,7 @@ export async function renderHighcharts(
             ...(option?.chart ?? {}),
             width,
             height,
+            backgroundColor: background || (option?.chart?.backgroundColor ?? 'transparent'),
             animation: false,
           },
         });
diff --git a/site/src/shared/chart-categories.ts b/site/src/shared/chart-categories.ts
index 59cec979..69d8f5e8 100644
--- a/site/src/shared/chart-categories.ts
+++ b/site/src/shared/chart-categories.ts
@@ -342,6 +342,49 @@ const figure = assemblePlotly(input);`,
       createChart('plotly', 'plotly-gauge', 'Gauge Chart *', 'ECharts: Gauge', gaugeIcon),
     ],
   },
+  {
+    id: 'highcharts',
+    label: BACKEND_LABELS.highcharts,
+    description:
+      'Compiles a Flint spec into a Highcharts options object — ideal for ' +
+      'editorial presentations, responsive publications, and print-ready visualizations ' +
+      'with native export and accessibility support.',
+    fn: 'assembleHighcharts',
+    snippet: `import { assembleHighcharts } from 'flint-chart';
+
+const input = {
+  data: {
+    values: [
+      { quarter: 'Q1', revenue: 120 },
+      { quarter: 'Q2', revenue: 156 },
+      { quarter: 'Q3', revenue: 143 },
+      { quarter: 'Q4', revenue: 189 },
+    ],
+  },
+  semantic_types: { quarter: 'Quarter', revenue: 'Amount' },
+  chart_spec: {
+    chartType: 'Bar Chart',
+    encodings: { x: 'quarter', y: 'revenue' },
+  },
+};
+
+const options = assembleHighcharts(input);`,
+    charts: [
+      createChart('highcharts', 'highcharts-bar', 'Bar Chart', 'Bar Chart', barIcon),
+      createChart('highcharts', 'highcharts-grouped-bar', 'Grouped Bar Chart', 'Grouped Bar Chart', groupedBarIcon),
+      createChart('highcharts', 'highcharts-stacked-bar', 'Stacked Bar Chart', 'Stacked Bar Chart', stackedBarIcon),
+      createChart('highcharts', 'highcharts-line', 'Line Chart', 'Line Chart', lineIcon),
+      createChart('highcharts', 'highcharts-area', 'Area Chart', 'Area Chart', areaIcon),
+      createChart('highcharts', 'highcharts-scatter', 'Scatter Plot', 'Scatter Plot', scatterIcon),
+      createChart('highcharts', 'highcharts-pie', 'Pie Chart', 'Pie Chart', pieIcon),
+      createChart('highcharts', 'highcharts-doughnut', 'Doughnut Chart', 'Doughnut Chart', doughnutIcon),
+      createChart('highcharts', 'highcharts-histogram', 'Histogram', 'Histogram', histogramIcon),
+      createChart('highcharts', 'highcharts-radar', 'Radar Chart', 'Radar Chart', radarIcon),
+      createChart('highcharts', 'highcharts-heatmap', 'Heatmap', 'Heatmap', heatmapIcon),
+      createChart('highcharts', 'highcharts-waterfall', 'Waterfall Chart', 'Waterfall Chart', waterfallIcon),
+      createChart('highcharts', 'highcharts-sunburst', 'Sunburst Chart', 'Sunburst Chart', sunburstIcon),
+    ],
+  },
 ];
 
 export const DEFAULT_CHART_ID = CHART_CATEGORIES[0]?.charts[0]?.id ?? 'scatter-plot';

From cbed7ef541a2e441e0fed945e3720d5b443eeac9 Mon Sep 17 00:00:00 2001
From: Sam 
Date: Wed, 21 Oct 2026 20:27:24 +0530
Subject: [PATCH 4/4] fix(site): inline getStroke in talk sketch to avoid
 missing module error

---
 site/src/talk/sketch.ts | 27 +++++++++++++++++++++++++--
 1 file changed, 25 insertions(+), 2 deletions(-)

diff --git a/site/src/talk/sketch.ts b/site/src/talk/sketch.ts
index b35239b9..8c773bda 100644
--- a/site/src/talk/sketch.ts
+++ b/site/src/talk/sketch.ts
@@ -1,10 +1,33 @@
 // Hand-drawn sketch primitives for the talk deck, ported from the video (promo-en/render/lib.js):
 // perfect-freehand strokes with a gentle wobble, watercolour washes, and Kalam lettering.
-import { getStroke } from 'perfect-freehand';
-
 type Ctx = CanvasRenderingContext2D;
 type Pt = [number, number];
 
+function getStroke(pts: (Pt | number[])[], options: any = {}): Pt[] {
+  if (pts.length < 2) return [];
+  const size = options.size ?? 4;
+  const left: Pt[] = [];
+  const right: Pt[] = [];
+  for (let i = 0; i < pts.length; i++) {
+    const p0 = pts[Math.max(0, i - 1)];
+    const p1 = pts[Math.min(pts.length - 1, i + 1)];
+    const dx = p1[0] - p0[0];
+    const dy = p1[1] - p0[1];
+    const len = Math.hypot(dx, dy) || 1;
+    const nx = -dy / len;
+    const ny = dx / len;
+    const t = options.start?.taper && i < pts.length * 0.2
+      ? (i / (pts.length * 0.2))
+      : (options.end?.taper && i > pts.length * 0.8
+        ? ((pts.length - 1 - i) / (pts.length * 0.2))
+        : 1);
+    const halfWidth = (size / 2) * Math.max(0.2, t);
+    left.push([pts[i][0] + nx * halfWidth, pts[i][1] + ny * halfWidth]);
+    right.push([pts[i][0] - nx * halfWidth, pts[i][1] - ny * halfWidth]);
+  }
+  return [...left, ...right.reverse()];
+}
+
 export const C = {
   paper: '#fbfbf8',
   card: '#ffffff',