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 abe6f955..9a8b99f1 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: @@ -83,16 +83,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 @@ -139,7 +141,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 @@ -420,6 +422,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/CHANGELOG.md b/CHANGELOG.md index dd20223e..10748dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,14 @@ Released together for `flint-chart`, `flint-chart-mcp`, and the Copilot Canvas p `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`. - `` from the new `flint-chart/react` subpath (React is an optional peer dependency). `spec` takes the `ChartAssemblyInput` with its `interaction_spec`; `interactions` takes preset factories, as an array added diff --git a/README.md b/README.md index a8ec486b..77ea9d88 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ for building interactive applications. Flint supports **52 chart types across 5 rendering backends** — [Vega-Lite](https://vega.github.io/vega-lite/), [ECharts](https://echarts.apache.org/), -[Chart.js](https://www.chartjs.org/), [Plotly](https://plotly.com/javascript/), and +[Chart.js](https://www.chartjs.org/), [Highcharts](https://www.highcharts.com/), [Plotly](https://plotly.com/javascript/), and native Excel charts — through one unified interface, with **14 themes and 23 interaction presets**. To create bespoke visualizations, design custom themes in the [Theme Lab](https://microsoft.github.io/flint-chart/#/theme-lab) and build @@ -236,8 +236,8 @@ and read the [interaction spec](docs/interaction-spec.md) and - [Project site](https://microsoft.github.io/flint-chart/) — gallery, live editor, themes, and interactions - [API reference](docs/api-reference.md) and 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 - [Excel](docs/reference-excel.md) + [Chart.js](docs/reference-chartjs.md), [Highcharts](docs/reference-highcharts.md), + [Plotly](docs/reference-plotly.md), and [Excel](docs/reference-excel.md) - [Theme guide](docs/theme-spec.md), [interaction spec](docs/interaction-spec.md), and [interaction programming API](docs/interaction-api.md) - [MCP server guide](https://microsoft.github.io/flint-chart/#/mcp) and @@ -283,6 +283,7 @@ flint-chart/ │ │ ├── vegalite/ Vega-Lite backend │ │ ├── echarts/ ECharts backend │ │ ├── chartjs/ Chart.js backend +│ │ ├── highcharts/ Highcharts backend │ │ ├── plotly/ Plotly backend │ │ ├── excel/ native Excel backend │ │ └── test-data/ fixtures and stress-test generators diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index 295f40c7..3a59883d 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: @@ -83,16 +83,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 @@ -139,7 +141,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 @@ -420,6 +422,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 58b18ac0..bb54dbb4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -727,7 +727,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 @@ -1052,7 +1052,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-chart-template.md b/docs/adding-a-chart-template.md index b267b501..34335703 100644 --- a/docs/adding-a-chart-template.md +++ b/docs/adding-a-chart-template.md @@ -35,7 +35,7 @@ Pick only the channels the mark actually uses. Copy a similar template as a star A new chart that is a variant of an existing one should look like it. Reuse the sibling's mark definition and properties (export them from the sibling's file), or call the sibling's `instantiate` on a unit of your own spec, so a theme and a reader see one family. The Ridgeline Plot draws its bands as the Range Area Chart does; the Marginal Plot builds its plot with the Scatter Plot's, Strip Plot's or Heatmap's `instantiate`, by the axis types, and its margins with the Density Plot's, Histogram's, Boxplot's or Bar Chart's. -ECharts, Chart.js and Plotly use the same `ChartTemplateDef` interface under their own `templates/` folders. +ECharts, Chart.js, Plotly, and Highcharts use the same `ChartTemplateDef` interface under their own `templates/` folders. --- @@ -178,6 +178,7 @@ Review formatting, layout stretch, legends and facet behaviour across 3–6 repr # §7 Wire the site, docs and agent skill +The user-facing contract is that the same `chartType` string should work across `assembleVegaLite`, `assembleECharts`, `assembleChartjs`, `assemblePlotly`, and `assembleHighcharts` **when templates exist**. Nothing below is read from the registry, so each needs a line: | Where | What | diff --git a/docs/api-reference.md b/docs/api-reference.md index 20a82ccf..edea6bdf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -59,6 +59,7 @@ import { assembleVegaLite, assembleECharts, assembleChartjs, + assembleHighcharts, assemblePlotly, assembleExcel, } from 'flint-chart'; @@ -66,6 +67,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); ``` @@ -75,10 +77,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 @@ -428,7 +431,7 @@ const result = validateChart(input, 'vegalite', { updates }); | `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` @@ -467,6 +470,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/interactive` | `mountChart`, interaction presets, update helpers | | `flint-chart/react` | `` component (React is an optional peer dependency) | 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..ba61b719 --- /dev/null +++ b/docs/reference-highcharts.md @@ -0,0 +1,140 @@ +# 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` + +_No template-specific parameters._ + +### ![](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 e3be42e7..36a4c36e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1446,7 +1446,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", @@ -4113,7 +4112,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", @@ -4873,7 +4871,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": { @@ -5989,6 +5986,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", @@ -7819,7 +7834,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": { @@ -7839,7 +7853,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" @@ -8351,7 +8364,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", @@ -10028,7 +10040,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/typedarray": { @@ -11003,7 +11014,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/wellknown": { @@ -11089,7 +11099,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" @@ -11222,6 +11231,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", "react": ">=18", @@ -11236,6 +11246,9 @@ "echarts": { "optional": true }, + "highcharts": { + "optional": true + }, "plotly.js": { "optional": true }, @@ -11268,6 +11281,8 @@ "chart.js": "^4.4.0", "echarts": "^6.0.0", "flint-chart": "^1.0.1", + "highcharts": "^13.1.1", + "puppeteer-core": "^25.4.0", "vega": "^6.0.0", "vega-interpreter": "^2.2.1", "vega-lite": "^6.0.0", @@ -11356,6 +11371,7 @@ "d3": "^7.9.0", "echarts": "^6.0.0", "flint-chart": "*", + "highcharts": "^13.1.1", "i18next": "^26.3.6", "json-stringify-pretty-compact": "^4.0.0", "katex": "^0.17.0", diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index 23915a33..b5286152 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", @@ -105,6 +110,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", @@ -143,6 +153,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", "react": ">=18", @@ -166,6 +177,9 @@ "chart.js": { "optional": true }, + "highcharts": { + "optional": true + }, "plotly.js": { "optional": true }, diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 0af5ee6d..d44aa455 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -1036,8 +1036,8 @@ export interface ChartTemplateDef { semanticInteractions?: (context: { resolvedEncodings: Readonly>; /** The encodings as Flint resolved them, before any backend spelling, and their semantics. */ - encodings: Readonly>; - channelSemantics: Readonly>; + encodings?: Readonly>; + channelSemantics?: Readonly>; }) => { fields: string[]; provenanceFields?: readonly string[]; diff --git a/packages/flint-js/src/highcharts/assemble.ts b/packages/flint-js/src/highcharts/assemble.ts new file mode 100644 index 00000000..47bc91e8 --- /dev/null +++ b/packages/flint-js/src/highcharts/assemble.ts @@ -0,0 +1,477 @@ +// 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 || !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] ? [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 ?? {}; + 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); + + // ═══════════════════════════════════════════════════════════════════════ + // 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); + + let hcOption: any; + + 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) { + 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 = [...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 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[] = []; + 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); + + // 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, + option: panelOption, + }); + } + panelRows.push(rowPanels); + } + + hcOption = hcCombineFacetPanels( + panelRows, + !!colField, + !!rowField, + wrapColumnOnly, + input.chart_spec.title !== undefined, + input.chart_spec.subtitle !== undefined, + ); + } 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; + } + + 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 (!hasFacet) { + capabilities.push('elements'); + } + 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; +} + +/** 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, + hasTitle = false, + hasSubtitle = 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 titleH = (hasTitle ? 28 : 0) + (hasSubtitle ? 16 : 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: titleH + 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..a5c60027 --- /dev/null +++ b/packages/flint-js/src/highcharts/index.ts @@ -0,0 +1,11 @@ +// 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'; 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..55b0b581 --- /dev/null +++ b/packages/flint-js/src/highcharts/instantiate-spec.ts @@ -0,0 +1,242 @@ +// 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 ──────────────────────────────────────────────── + // ── Canvas dimensions ──────────────────────────────────────────────── + // Highcharts sizes to the chart container; the site renderer uses a + // 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); + } 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 { + option._width = layout.subplotWidth || canvasSize.width; + option._height = layout.subplotHeight || canvasSize.height; + } + option.chart = option.chart || {}; + option.chart.width = option._width; + option.chart.height = option._height; + } + + // ── Zero-baseline and domain padding (value axes) ─────────────────────── + 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 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 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) { + 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 + .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 ──────────────────────────────────────────────────── + const titleText = context.chartProperties?.title; + if (titleText != null && titleText !== '') { + option.title = { text: String(titleText) }; + } else { + option.title = { text: null }; + } + const subtitleText = context.chartProperties?.subtitle; + if (subtitleText != null && subtitleText !== '') { + option.subtitle = { text: String(subtitleText) }; + } + + // ── 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]; + 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) ──────────────────── + 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..827153d6 --- /dev/null +++ b/packages/flint-js/src/highcharts/interactive.ts @@ -0,0 +1,358 @@ +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'; +import type { ChartUpdate, ChartUpdateResult } from '../interactive/language/updates'; +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[], + 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 interface HighchartsRendererOptions { + interactions?: any[]; +} + +export function createHighchartsInteractiveRenderer(rendererOptions?: HighchartsRendererOptions): 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 currentCharts: Highcharts.Chart[] = []; + let destroyed = false; + let updateTimer: number | undefined; + 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) => { + const points = (series.points ?? []) as Array) => void }; + }>; + const seriesOpacity = activeSelection + ? (seriesIndex === activeSelection.series ? 1 : dimOpacity) + : 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 && currentCharts.length > 0 && !destroyed) return; + lastOptionJson = optionJson; + + if (currentCharts.length > 0) { + for (const c of currentCharts) { + try { + c.destroy(); + } catch { + // ignore teardown errors + } + } + currentCharts = []; + } + wrapper.replaceChildren(); + + const isFaceted = option?._facet && Array.isArray(option?._facetPanels); + + const decorateOption = (panelOpt: any) => { + const plotOptions = panelOpt?.plotOptions ?? {}; + 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, + ...(zoomType ? { zoomType, zooming: { type: zoomType } } : {}), + }, + 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); + }, + }, + }, + }, + }, + }; + }; + + 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(); + }; + + 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 chart = currentCharts[0]; + if (!chart || chart.plotLeft == null || chart.plotWidth == null) return undefined; + return channel === 'x' + ? { offset: chart.plotLeft, extent: chart.plotWidth } + : { offset: chart.plotTop, extent: chart.plotHeight }; + }, + getInteractionContext() { + const selected: SemanticElement[] = []; + 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 { + 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); + for (const chart of currentCharts) { + try { + chart.destroy(); + } catch { + // ignore teardown errors + } + } + currentCharts = []; + wrapper.remove(); + }, + }; + }, + }; +} + +interface ViewportChange { + axes: 'x' | 'y' | 'xy'; + 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 && 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: { x, y }, + }; + } + } + 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..bd582934 --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/bar.ts @@ -0,0 +1,452 @@ +// 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 { 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: { + categories, + title: { text: catField }, + labels: { rotation: isHorizontal ? 0 : categoryAxisLabelRotateDeg(categories, catCS?.type) }, + }, + yAxis: { 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: { + categories, + title: { text: catField }, + labels: { rotation: isHorizontal ? 0 : categoryAxisLabelRotateDeg(categories, catCS?.type) }, + }, + yAxis: { 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, chartProperties } = 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: chartProperties?.stackMode === 'percent' ? 'percent' : 'normal' } }, + series, + }; + Object.assign(spec, option); + delete spec.mark; + delete spec.encoding; + return; + } + + const stackMode = chartProperties?.stackMode === 'percent' ? 'percent' : 'normal'; + const option: any = { + chart: { type: isHorizontal ? 'bar' : 'column' }, + 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: stackMode } }, + 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, + }; + 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: { + categories, + title: { text: catField }, + labels: { rotation: isHorizontal ? 0 : categoryAxisLabelRotateDeg(categories, catCS?.type) }, + }, + yAxis: { 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: [], + 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..88abab50 --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/line.ts @@ -0,0 +1,294 @@ +// 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, + 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); + 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]); + } + pairs.sort((a, b) => a[0] - b[0]); + 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, + xCS: any, + yField: string, + colorField: string | undefined, + sizeField: string | undefined, + yCategories?: string[], +): any[] { + const xField = xCS.field; + const { dataMode } = buildLineXAxis(ctx, xCS); + const allCategories = dataMode === 'category' + ? extractCategories(table, xField, getCategoryOrder(ctx, 'x')) + : []; + + const series: any[] = []; + + if (colorField) { + const groups = groupBy(table, colorField); + for (const [name, rows] of groups) { + series.push({ + name, + data: dataMode === 'category' + ? buildLineCategoryValues(rows, xField, yField, allCategories, yCategories) + : buildPairs(rows, xField, yField, xCS.type === 'temporal', yCategories), + ...(sizeField ? { marker: { radius: 5 } } : {}), + }); + } + return series; + } + + series.push({ + name: yField, + data: dataMode === 'category' + ? buildLineCategoryValues(table, xField, yField, allCategories, yCategories) + : buildPairs(table, xField, yField, xCS.type === 'temporal', yCategories), + ...(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 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, 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, + legend: isDiscreteColor + ? { 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, chartProperties } = ctx; + const xCS = channelSemantics.x; + const yCS = channelSemantics.y; + const colorField = channelSemantics.color?.field; + + 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, 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'; + + const option: any = { + chart: { type: chartType }, + xAxis, + yAxis, + legend: isDiscreteColor + ? { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' } + : { enabled: false }, + plotOptions: { + [chartType]: { + 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..09467473 --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/pie.ts @@ -0,0 +1,158 @@ +// 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 }); + } + } + + 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; +} + +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', + showInLegend: !!colorField, + ...(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..b8657918 --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/radar.ts @@ -0,0 +1,115 @@ +// 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 seriesType = filled ? 'area' : 'line'; + + 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: seriesType, + 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: seriesType, + 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: seriesType }, + 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..6dc69016 --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/scatter.ts @@ -0,0 +1,185 @@ +// 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' (or 'bubble' when size channel is present). + * Supports numeric, temporal, and categorical channels on both x and y. + */ + +import { ChartTemplateDef, EncodingActionDef, ChannelSemantics } from '../../core/types'; +import { groupBy, extractCategories } from './utils'; +import { makeCartesianPivot } from '../../core/pivot'; +import { makeSortAction } from '../../core/encoding-actions'; + +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 = 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, + 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 = xToCoord(row[xField]); + const y = yToCoord(row[yField]); + const z = Number(row[sizeField]); + if (x === null || y === null) 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 xMap = resolveScatterAxis(table, xCS, 'x'); + const yMap = resolveScatterAxis(table, yCS, 'y'); + + const useBubble = !!sizeField; + const series: any[] = []; + + const isContinuousColor = colorField && channelSemantics.color?.type === 'quantitative'; + + if (colorField && !isContinuousColor) { + 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, xMap.toCoord, yMap.toCoord) + : 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, + type: useBubble ? 'bubble' : 'scatter', + data: useBubble + ? 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: useBubble ? 'bubble' : 'scatter' }, + xAxis: xMap.axisConfig, + yAxis: yMap.axisConfig, + ...(isContinuousColor ? { colorAxis: {} } : {}), + 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..e180caf6 --- /dev/null +++ b/packages/flint-js/src/highcharts/templates/sunburst.ts @@ -0,0 +1,123 @@ +// 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); + + const data: any[] = []; + 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: 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..3c717061 --- /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 isClosingTotal = t === 'end'; + const isTotal = t === 'start' || isClosingTotal; + const point: any = { + y: v, + color: isTotal ? TOTAL_COLOR : v >= 0 ? UP_COLOR : DOWN_COLOR, + }; + if (isClosingTotal) point.isSum = true; + return point; + }); + + const option: any = { + chart: { type: 'column' }, + xAxis: { + categories, + title: { text: catField }, + }, + yAxis: { + title: { text: valField }, + }, + legend: { enabled: false }, + tooltip: { + pointFormat: '{series.name}: {point.y}', + }, + 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 23d5197f..11a8303a 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 f03ebf39..8432e957 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -294,6 +294,20 @@ export function mountChart( }, { className, ariaLabel, chartId, updates, interactions, warnings }, ); + case 'highcharts': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createHighchartsInteractiveRenderer } = await import('../highcharts/interactive'); + return createHighchartsInteractiveRenderer({ + interactions: canvasInteractions, + }).mount(chartContainer, await sized(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 b8e0758f..adfd8ae1 100644 --- a/packages/flint-js/src/interactive/spec/compose.ts +++ b/packages/flint-js/src/interactive/spec/compose.ts @@ -45,7 +45,7 @@ export function composeInteractiveOptions(input: ComposeInput, options: ComposeO }); return false; }); - if (options.backend !== 'vegalite' && specInteractions.length + code.length > 0) { + if (options.backend !== 'vegalite' && options.backend !== 'highcharts' && specInteractions.length + code.length > 0) { warnings.push({ severity: code.length > 0 ? 'warning' : 'info', code: 'interactions_ignored', diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts index 1e487e5f..c526fbb7 100644 --- a/packages/flint-js/src/interactive/types.ts +++ b/packages/flint-js/src/interactive/types.ts @@ -132,7 +132,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 c2f7c4ca..529d16c8 100644 --- a/packages/flint-js/src/test-data/index.ts +++ b/packages/flint-js/src/test-data/index.ts @@ -42,6 +42,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'; @@ -122,6 +123,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 { @@ -268,6 +270,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 73fa7c8c..a812bb86 100644 --- a/packages/flint-js/src/validate/index.ts +++ b/packages/flint-js/src/validate/index.ts @@ -39,15 +39,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. */ @@ -100,6 +103,7 @@ const ASSEMBLERS: Record any> echarts: assembleECharts, chartjs: assembleChartjs, plotly: assemblePlotly, + highcharts: assembleHighcharts, }; const TEMPLATE_LOOKUP: Record< @@ -110,6 +114,7 @@ const TEMPLATE_LOOKUP: Record< echarts: ecGetTemplateDef, chartjs: cjsGetTemplateDef, plotly: plGetTemplateDef, + highcharts: hcGetTemplateDef, }; const FIELD_SORT: Record = { @@ -117,6 +122,7 @@ const FIELD_SORT: Record = { echarts: false, chartjs: false, plotly: false, + highcharts: false, }; /** @@ -338,7 +344,7 @@ function admitSpecInteractions( assembled: unknown, ): { admitted: readonly CanvasInteractionDef[]; warnings: ChartWarning[] } { if (!input.interaction_spec) return { admitted: [], warnings: [] }; - if (backend !== 'vegalite') { + if (backend !== 'vegalite' && backend !== 'highcharts') { return { admitted: [], warnings: [{ diff --git a/packages/flint-js/src/vegalite/templates/table.ts b/packages/flint-js/src/vegalite/templates/table.ts index 5def8c2d..9e21a6e1 100644 --- a/packages/flint-js/src/vegalite/templates/table.ts +++ b/packages/flint-js/src/vegalite/templates/table.ts @@ -67,8 +67,8 @@ export const tableDef: ChartTemplateDef = { ), structure: () => ({ marks: ['rule', 'text'], stacking: 'none' }), semanticInteractions: ({ encodings, channelSemantics }) => { - const fields = Object.values(encodings).flatMap((encoding) => (encoding?.field ? [encoding.field] : [])); - const temporal = Object.values(channelSemantics).flatMap((cs) => (cs.type === 'temporal' ? [cs.field] : [])); + const fields = Object.values(encodings ?? {}).flatMap((encoding) => (encoding?.field ? [encoding.field] : [])); + const temporal = Object.values(channelSemantics ?? {}).flatMap((cs) => (cs.type === 'temporal' ? [cs.field] : [])); return { fields, provenanceFields: fields, 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..07151886 --- /dev/null +++ b/packages/flint-js/tests/highcharts-backend.test.ts @@ -0,0 +1,392 @@ +// 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.xAxis.categories).toEqual(['North', 'East', 'South']); + expect(option.yAxis.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('area'); + }); + + 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 }, + { Step: 'Total', Value: 120 }, + ]; + 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', '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', () => { + 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 59fa4e85..4b168bc3 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, @@ -60,6 +61,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 a676e7f4..52b31936 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', @@ -18,6 +19,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', @@ -30,8 +32,8 @@ export default defineConfig({ treeshake: true, target: 'es2020', external: [ - 'vega', 'vega-lite', 'vega-tooltip', 'echarts', 'chart.js', 'plotly.js', 'plotly.js-dist-min', 'react', - '../vegalite/interactive', '../echarts/interactive', '../chartjs/interactive', '../plotly/interactive', + 'vega', 'vega-lite', 'vega-tooltip', 'echarts', 'chart.js', 'highcharts', 'plotly.js', 'plotly.js-dist-min', 'react', + '../vegalite/interactive', '../echarts/interactive', '../chartjs/interactive', '../highcharts/interactive', '../plotly/interactive', '../validate/links', ], }); diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index 295f40c7..3a59883d 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: @@ -83,16 +83,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 @@ -139,7 +141,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 @@ -420,6 +422,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 19aff417..9fa55afa 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": "^1.0.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 dade65fc..9e25a464 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..a1e0d818 --- /dev/null +++ b/packages/flint-mcp/src/render/highcharts.ts @@ -0,0 +1,255 @@ +// 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; + +export interface HighchartsRenderArgs { + format: RenderFormat; + scale: number; + background: string; + width: number; + 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 + * 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 || option?._width || DEFAULT_W; + const height = args.height || option?._height || DEFAULT_H; + + 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(); + await page.setViewport({ width, height, deviceScaleFactor: args.scale }); + + const html = ` +`; + + 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( + (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, + backgroundColor: background || (panel.option?.chart?.backgroundColor ?? 'transparent'), + 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, + backgroundColor: background || (option?.chart?.backgroundColor ?? 'transparent'), + 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, { + 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 b21036a8..fc48f538 100644 --- a/packages/flint-mcp/src/render/index.ts +++ b/packages/flint-mcp/src/render/index.ts @@ -10,6 +10,7 @@ import { renderVegaLite } from './vegalite.js'; import { installFlintTextMeasurer } from './fonts.js'; import { renderECharts } from './echarts.js'; import { renderChartjs } from './chartjs.js'; +import { renderHighcharts } from './highcharts.js'; import type { RenderBackend, RenderFormat, @@ -43,6 +44,7 @@ export const RENDERABLE_BACKENDS: readonly RenderBackend[] = [ 'vegalite', 'echarts', 'chartjs', + 'highcharts', ]; /** @@ -82,7 +84,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) { @@ -112,6 +126,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 a7d982b3..33a897c7 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 c25f1111..dfd73485 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, listInteractionPresets as listPresetSummaries, supportedInteractionPresets, @@ -19,6 +20,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 ff3686d0..15c78486 100644 --- a/packages/flint-mcp/src/tools/schemas.ts +++ b/packages/flint-mcp/src/tools/schemas.ts @@ -9,8 +9,8 @@ import { 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/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/packages/flint-mcp/tests/server.test.ts b/packages/flint-mcp/tests/server.test.ts index 53bd144b..5f0db502 100644 --- a/packages/flint-mcp/tests/server.test.ts +++ b/packages/flint-mcp/tests/server.test.ts @@ -412,7 +412,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 a36dbced..577440de 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 528e0249..8fac8e5d 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", "json-stringify-pretty-compact": "^4.0.0", "katex": "^0.17.0", diff --git a/site/src/components/HighchartsView.tsx b/site/src/components/HighchartsView.tsx new file mode 100644 index 00000000..8afd41d3 --- /dev/null +++ b/site/src/components/HighchartsView.tsx @@ -0,0 +1,161 @@ +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(() => { + return () => { + try { + chartRef.current?.destroy(); + } catch { + // A chart that failed to initialize can throw on destroy; unmount + // must still complete. + } + chartRef.current = null; + }; + }, []); + + return ( + <> + {error && ( +
+          {error}
+        
+ )} +
+ + ); +} + +/** 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/i18n/messages/en.json b/site/src/i18n/messages/en.json index f980c8c3..705673e5 100644 --- a/site/src/i18n/messages/en.json +++ b/site/src/i18n/messages/en.json @@ -465,6 +465,7 @@ "closingTitle": "Start building with Flint.", "closingBody": "Open source and ready to use. Start from GitHub or browse examples in the gallery.", "viewGithub": "View on GitHub", + "closingCollab": "Flint is built by Microsoft Research in collaboration with the IDEAS Lab, Renmin University of China.", "contributors": "Contributors", "wechatTitle": "Join the Flint WeChat group", "wechatBody": "Scan the QR code to join the Flint user WeChat group and chat with other users.", diff --git a/site/src/i18n/messages/zh-CN.json b/site/src/i18n/messages/zh-CN.json index 582589a8..1b8ca868 100644 --- a/site/src/i18n/messages/zh-CN.json +++ b/site/src/i18n/messages/zh-CN.json @@ -465,6 +465,7 @@ "closingTitle": "开始使用 Flint", "closingBody": "Flint 已开源,可直接使用。前往 GitHub 获取源码,或先浏览图表示例。", "viewGithub": "在 GitHub 上查看", + "closingCollab": "Flint 由 Microsoft Research 与中国人民大学 IDEAS Lab 合作构建。", "contributors": "贡献者", "wechatTitle": "加入 Flint 用户微信群", "wechatBody": "扫码加入 Flint 用户微信群,与其他用户交流。", diff --git a/site/src/playground/OverflowViewportLab.tsx b/site/src/playground/OverflowViewportLab.tsx index 83f8d19b..257ba61c 100644 --- a/site/src/playground/OverflowViewportLab.tsx +++ b/site/src/playground/OverflowViewportLab.tsx @@ -106,7 +106,10 @@ function GeneralViewportDemo({ description: string; }) { const [backend, setBackend] = useState(initialBackend); - const availableBackends = getSupportedBackends(input.chart_spec.chartType); + // 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: PreviewBackend[] = (['vegalite', 'echarts', 'chartjs', 'plotly', 'highcharts'] 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 4096d3fc..ea3fa56c 100644 --- a/site/src/routes/Editor.tsx +++ b/site/src/routes/Editor.tsx @@ -312,6 +312,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/routes/Landing.tsx b/site/src/routes/Landing.tsx index d5e08ede..95655f98 100644 --- a/site/src/routes/Landing.tsx +++ b/site/src/routes/Landing.tsx @@ -46,12 +46,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); 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'; diff --git a/site/src/shared/docs-catalog.ts b/site/src/shared/docs-catalog.ts index 8ac373eb..3ff99f55 100644 --- a/site/src/shared/docs-catalog.ts +++ b/site/src/shared/docs-catalog.ts @@ -160,6 +160,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', diff --git a/site/src/talk/sketch.ts b/site/src/talk/sketch.ts index 20ef0bf9..d6381258 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',