diff --git a/.github/release-notes/0.5.0.md b/.github/release-notes/0.5.0.md index 588651b7..edc4fa20 100644 --- a/.github/release-notes/0.5.0.md +++ b/.github/release-notes/0.5.0.md @@ -1,20 +1,12 @@ # Flint 0.5.0: Formal visual themes -Flint 0.5 introduces `ThemeSpec`, a formal specification for applying a -coherent design system across an entire chart library. Instead of requiring -creators or agents to reproduce design guidance chart by chart, a ThemeSpec -participates directly in compilation and adapts to the chart's semantics, -data, cardinality, and available space. - -A ThemeSpec shapes compilation in three stages. Using resolved semantic -information, it: - -- governs layout constraints and dynamics, including density, spacing, sizing, - and stretch limits; -- conditionally resolves visual preferences for labels, legends, axes, - annotations, and semantic roles; and -- supplies design fixtures such as typography, color, surfaces, mark geometry, - and structural line styles to backend-specific code generation. +Flint 0.5 introduces `ThemeSpec`, a formal specification for applying a coherent design system across an entire chart library. Instead of requiring creators or agents to reproduce design guidance chart by chart, a ThemeSpec participates directly in compilation and adapts to the chart's semantics, data, cardinality, and available space. + +A ThemeSpec shapes compilation in three stages. Using resolved semantic information, it: + +- governs layout constraints and dynamics, including density, spacing, sizing, and stretch limits; +- conditionally resolves visual preferences for labels, legends, axes, annotations, and semantic roles; and +- supplies design fixtures such as typography, color, surfaces, mark geometry, and structural line styles to backend-specific code generation.

ThemeSpec shaping the Flint compilation process through resolved semantics, layout constraints, visual preferences, and design fixtures. @@ -22,14 +14,11 @@ information, it: ThemeSpec participates throughout compilation, from resolved semantics and layout dynamics to conditional visual preferences and backend design fixtures.

-This release also adds ten built-in themes, custom and inherited ThemeSpecs, -public theme APIs, a visual-theme explorer and Theme Lab, plus theme discovery -and authoring support in the Flint MCP server and MCP App. +This release also adds ten built-in themes, custom and inherited ThemeSpecs, public theme APIs, a visual-theme explorer and Theme Lab, plus theme discovery and authoring support in the Flint MCP server and MCP App. ## Use a visual theme -Add `theme_spec` beside `chart_spec`. The chart spec continues to define what -the chart means; the theme defines how that meaning is presented. +Add `theme_spec` beside `chart_spec`. The chart spec continues to define what the chart means; the theme defines how that meaning is presented. ```json { @@ -46,10 +35,7 @@ the chart means; the theme defines how that meaning is presented. ## Ten built-in themes -Flint ships New York Times, Economist, Swiss, Nature, McKinsey, Datawrapper, -Power BI, Power BI Light, Pop, and Cartoon presets. The -[visual-theme explorer](https://microsoft.github.io/flint-chart/#/themes) -applies each preset to the same set of charts for direct comparison. +Flint ships New York Times, Economist, Swiss, Nature, McKinsey, Datawrapper, Power BI, Power BI Light, Pop, and Cartoon presets. The [visual-theme explorer](https://microsoft.github.io/flint-chart/#/themes) applies each preset to the same set of charts for direct comparison.

@@ -77,8 +63,7 @@ applies each preset to the same set of charts for direct comparison. ## Create a brand theme -Pass a custom `ThemeSpec`, or inherit a built-in preset and override only the -decisions that should differ: +Pass a custom `ThemeSpec`, or inherit a built-in preset and override only the decisions that should differ: ```json { @@ -98,30 +83,19 @@ Nested objects merge; arrays and scalar values replace inherited values. ## Tools and integrations -- The [visual-theme explorer](https://microsoft.github.io/flint-chart/#/themes) - compares presets across the same chart wall. -- [Theme Lab](https://microsoft.github.io/flint-chart/#/theme-lab) tests custom - ThemeSpecs across chart types and data shapes. -- The MCP server and MCP App add theme discovery, preset selection, and custom - ThemeSpec support through `list_themes`. -- The bundled `flint://theme-skill` resource and `author_flint_theme` prompt - help agents translate design guidance into reusable ThemeSpecs. -- Public APIs include `ThemeSpec`, `ThemePreset`, `THEME_PRESETS`, - `listThemePresets()`, and `resolveThemeSpec()`. -- The theme explorer, Theme Lab, and authoring guidance are available in - English and Chinese. - -The release also improves Vega-Lite logarithmic tick and grid spacing, line -endpoint guides, and heatmap grid treatment. - -ThemeSpec is currently realized by the Vega-Lite backend. Other backends still -accept the shared Flint input but do not yet apply `theme_spec`. Existing inputs -without a theme retain Flint's default behavior. - -See [Using themes](https://microsoft.github.io/flint-chart/#/documentation/theme-spec) -for the complete vocabulary and examples. - -See the [changelog](https://github.com/microsoft/flint-chart/blob/main/CHANGELOG.md) -for the complete technical summary. +- The [visual-theme explorer](https://microsoft.github.io/flint-chart/#/themes) compares presets across the same chart wall. +- [Theme Lab](https://microsoft.github.io/flint-chart/#/theme-lab) tests custom ThemeSpecs across chart types and data shapes. +- The MCP server and MCP App add theme discovery, preset selection, and custom ThemeSpec support through `list_themes`. +- The bundled `flint://theme-skill` resource and `author_flint_theme` prompt help agents translate design guidance into reusable ThemeSpecs. +- Public APIs include `ThemeSpec`, `ThemePreset`, `THEME_PRESETS`, `listThemePresets()`, and `resolveThemeSpec()`. +- The theme explorer, Theme Lab, and authoring guidance are available in English and Chinese. + +The release also improves Vega-Lite logarithmic tick and grid spacing, line endpoint guides, and heatmap grid treatment. + +ThemeSpec is currently realized by the Vega-Lite backend. Other backends still accept the shared Flint input but do not yet apply `theme_spec`. Existing inputs without a theme retain Flint's default behavior. + +See [Using themes](https://microsoft.github.io/flint-chart/#/documentation/theme-spec) for the complete vocabulary and examples. + +See the [changelog](https://github.com/microsoft/flint-chart/blob/main/CHANGELOG.md) for the complete technical summary. **Full Changelog**: https://github.com/microsoft/flint-chart/compare/0.4.0...0.5.0 diff --git a/.github/release-notes/0.5.1.md b/.github/release-notes/0.5.1.md new file mode 100644 index 00000000..9e6d5f27 --- /dev/null +++ b/.github/release-notes/0.5.1.md @@ -0,0 +1,62 @@ +# Flint 0.5.1: Themes on Plotly, and a calendar heatmap + +Flint 0.5.1 extends the theme system introduced in 0.5 to a second backend, adds Vega-Lite Calendar Heatmap, and tightens how charts fit the space they are given. + +## Themes on Plotly + +`theme_spec` now realizes onto Plotly figures. The neutral grounding stage is reused unchanged from Vega-Lite — no field in the theme layer moved to accommodate Plotly, which is the first evidence that the neutral layer really is neutral. + +```json +{ + "chart_spec": { + "chartType": "Line Chart", + "encodings": { + "x": { "field": "month" }, + "y": { "field": "revenue" } + } + }, + "theme_spec": "economist", + "backend": "plotly" +} +``` + +Surface, typography, axes, marks, series ink, legend, facet chrome, and data labels all follow the house. Where Plotly cannot do what a decision asks, it approximates and records what it did in `figure._theme.report`. `assemblePlotly` also renders `chart_spec.title` for the first time — a house's headline treatment is most of what makes it recognisable, and there was nothing to treat. + +The Plotly templates were reworked against a paired audit with themed Vega-Lite: line point density, pie label wrapping, rose wedge totals and polar guides, radar layout, facet chrome, and figure cropping all move toward parity. + +## Calendar Heatmap + +A new Vega-Lite chart type for daily values on a Monday-first week grid. + +```json +{ + "chart_spec": { + "chartType": "Calendar Heatmap", + "encodings": { + "x": { "field": "date" }, + "color": { "field": "commits" } + }, + "chartProperties": { "cornerRadius": 2 } + } +} +``` + +`cornerRadius` squares the cells into a continuous field or rounds them into tiles, and the `github` color scheme reproduces the familiar contribution graph through a quantile scale. + +## Charts that fit the space they are given + +Three changes to sizing, each visible on charts that were previously cramped or overgrown: + +- **Bands are fitted per house.** The new `layout.bandStepFit` field states how far a preset will grow a bar toward its slot when a chart has few categories. All ten presets are calibrated from first-party sources, so a Datawrapper bar fills its step and a Nature bar stays slim. +- **Furniture is reserved before bands are fitted.** Value labels, tick gutters, the title block, and the legend are counted first, so a house no longer sizes bands against room its own furniture is about to take. +- **Long headlines are fitted rather than left to widen the graphic.** A headline that only overhangs its block is left alone, one that overhangs further is set down a size, and only a headline that still does not fit is broken — over even lines, with the height taken out of the plot rather than added to the canvas. + +## Fixes + +- A closing rule under a banded plot runs the width of the bands, not the width a continuous plot would have taken, so a house that draws one no longer stretches the canvas past the chart. +- Waterfall declares its step axis as the band it is drawn on, so a temporal waterfall is sized per category instead of as a continuous run. Fixed in both the JavaScript and Python implementations. +- A house no longer prints a value label off a template's internal working column, which leaked computed values onto Vega-Lite waterfalls. +- Calendar heatmaps drop the plot frame, which had drawn a box around the days a part week does not have. +- Straight axis labels that cannot fit their band are no longer held flat by a house that prefers them straight. + +**Full Changelog**: https://github.com/microsoft/flint-chart/blob/main/CHANGELOG.md diff --git a/.gitignore b/.gitignore index a5c88ef0..2aa615c7 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,11 @@ loops/ # Generated eval-results data for the flint-py results viewer (8.5MB+, contains # run-specific paths/tracebacks). Regenerate locally via tools/build_results_page.py. packages/flint-py/tools/viewer/results.js + +# Theme audit renders and bundled audit scripts +audit-out/ +scripts/.*.mjs + +# Third-party theme research artifacts. Keep provenance and findings tracked; +# source PDFs/images remain local and are reproducible from the manifest. +theme-evidence/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ab598583..a87c26c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,106 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.1] - 2026-08-13 + +### Added + +- Theme support for the Plotly backend. `theme_spec` now realizes onto Plotly + figures — surface, typography, axes, marks, series ink, legend, facet chrome, + and data labels — reusing the same neutral grounding stage as Vega-Lite. Where + Plotly cannot honor a decision it approximates and records what it did in + `figure._theme.report`. `assemblePlotly` also renders `chart_spec.title` for + the first time. +- A Vega-Lite **Calendar Heatmap** chart type: daily values on a Monday-first + week grid, with a `cornerRadius` chart property and a canonical GitHub look + through a quantile color scale. +- Per-house sparse band fitting through the new `layout.bandStepFit` field, so + each preset states how far it will grow a bar toward its slot when a chart has + few categories. All ten presets are calibrated from first-party sources. + +### Changed + +- An axis title that is still needed now lies flat at the head of its own ruler, + beside the values it names and on whichever side those values sit, rather than + being turned on its side. Where two rulers each carry a measure, both are + named: one name cannot say which quantity is horizontal and which is vertical. +- A measure axis that draws a grid rounds its domain to the tick count the axis + actually draws, so the plot's edge falls on a grid line instead of stopping + short of one and leaving the outermost reading with nothing above it. Axes + that draw no grid, and non-linear rulers whose ticks are decades rather than a + count, are left alone. On a dot scale the rounding is applied to the data + rather than to the domain the renderer has already opened by a dot's radius: + rounding the padded domain turned a few pixels of clearance into a whole extra + interval, and a score bounded 0 to 100 came out running -10 to 110. +- A measure axis that draws a grid but no ticks carries that grid a few pixels + past the plot, in grid ink, so each line ends under its own number. Flush with + the last mark — a histogram's final bar — the line led nowhere. +- A unit is stated once. Where the axis title already carries it, the ruler no + longer repeats it down every tick. +- Economist axis labels and titles are calibrated against the 300x300 and + 400x300 canvases the library actually draws, and the house now lays its axis + titles flat rather than turning them on their side. +- McKinsey declares the `deck` and `axisTitle` type roles it had left implicit + and quiets `axisLabel`. Raising one role while leaving others to the global + defaults had inverted the ranking: the tick labels were the second-largest + text on the chart, above the subtitle and the axis title. +- Vega-Lite reserves the margins a chart will actually draw — value labels, tick + gutters, the title block, and the legend — before fitting bands, so a wide + house no longer sizes bands against room its own furniture will take. +- Long headlines are fitted rather than left to widen the graphic. A headline + that only overhangs its block is left alone, one that overhangs further is set + down a size, and only a headline that still does not fit is broken — over even + lines, with the height taken out of the plot rather than added to the canvas. +- A long deck is fitted the same way, and was not before: Vega-Lite measures it + as one unbroken run and grows the canvas to fit, so a deck alone took a 420px + chart out past 1,200px even where the headline above it had been brought to + heel. It is only ever broken, never set smaller — the deck is already the + quietest line in the block. +- Both are measured against the whole graphic rather than the plot rectangle. + The axis gutter beneath a title is the title's to use, and measuring without + it broke a headline that had room to spare beside its row labels. +- Both now run when no house is named. Staying inside the size the caller asked + for is a Flint concern rather than a house preference, and an un-themed chart + was getting no fitting at all. +- Plotly line, bar, scatter, pie, rose, radar, slope, heatmap, ranged-dot, and + KPI-card templates were reworked against a paired audit with themed Vega-Lite: + point density, label wrapping, wedge totals, polar guides, facet chrome, and + cropping all move toward parity. + +### Fixed + +- A house that omits its grid, its rule and its ticks does so because the value + is printed on the mark — a consulting-deck bar. A scatter prints nothing, and + the same house left its readings floating with no way to judge one against + another. Where no mark prints its value and the ruler draws nothing, the quiet + grid returns. A house may choose how a value is read; it may not leave no way + to read one. +- A measure axis no longer surrenders the right margin to series-end labels that + are never drawn. The placement was chosen off the house's ranked list whether + or not there was a key to place, so a single-series line chart evicted its own + ruler to the left and lost the house's opposite-seated axis on the plainest + chart it draws. +- Series-end and band-end labels no longer reserve canvas margin the renderer + has already reserved. They are ordinary text marks, and the default `pad` + autosize already grows the canvas to the scene's bounds, so the estimate was + counted twice — around 90px of dead margin per chart. +- A flat axis title no longer lands on the side its values are not. On a + right-seated ruler it was laid at the plot's left edge, captioning the wrong + column, and only the first of two rulers was ever laid flat. +- A flat axis title clears the topmost value rather than sitting on it. +- A closing rule drawn under a banded plot now runs the width of the bands + rather than the width a continuous plot would have taken, so a house that + draws one no longer stretches the canvas past the chart. +- Waterfall now declares its step axis as the band it is drawn on, so a temporal + waterfall is sized per category instead of as a continuous run. Fixed in both + the JavaScript and Python implementations. +- A house no longer prints a value label off a template's internal working + column, which leaked computed values onto Vega-Lite waterfalls. +- Calendar heatmaps drop the plot frame, which had drawn a box around the days + a part week does not have. +- Straight axis labels that cannot fit their band are no longer held flat by a + house that prefers them straight. + ## [0.5.0] - 2026-08-05 ### Added @@ -218,7 +318,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Treated only lowercase `start` and `end` Waterfall Type values as total anchors in Vega-Lite; other values now remain floating deltas colored by sign. -[Unreleased]: https://github.com/microsoft/flint-chart/compare/0.5.0...HEAD +[Unreleased]: https://github.com/microsoft/flint-chart/compare/0.5.1...HEAD +[0.5.1]: https://github.com/microsoft/flint-chart/compare/0.5...0.5.1 [0.5.0]: https://github.com/microsoft/flint-chart/compare/0.4.0...0.5.0 [0.4.1]: https://github.com/microsoft/flint-chart/compare/0.4.0...0.4.1 [0.4.0]: https://github.com/microsoft/flint-chart/compare/0.3.0...0.4.0 diff --git a/README.md b/README.md index 3699da4e..f61ef7e1 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,14 @@ Flint is a visualization intermediate language that lets **AI agents turn simple, human-editable chart specs into expressive, polished visualizations**. -Rather than requiring agents or developers to tune verbose settings for scales, -axes, spacing, labels, and layout, Flint derives those decisions from the data, -semantic types, chart type, encodings, and an optional visual theme. Users can -use a compact spec to create visually polished, brand-consistent charts rendered -as native +Rather than requiring agents to hardcode verbose parameters for scales, axes, +spacing, labels, and layout, Flint derives those decisions from a high-level +semantic specification, the data, and an optional visual theme (e.g., New York +Times, Economist, Swiss, and McKinsey). A compact spec compiles to native [Vega-Lite](https://vega.github.io/vega-lite/), [ECharts](https://echarts.apache.org/), [Chart.js](https://www.chartjs.org/), -[Plotly](https://plotly.com/javascript/) specs, or as native Excel charts -through Office.js. +[Plotly](https://plotly.com/javascript/) specs, or editable Excel charts. This repo contains two main components: @@ -28,6 +26,27 @@ This repo contains two main components: - **`flint-chart-mcp`**: an MCP server that lets agents create, validate, and render charts directly from a chat or coding environment. +### Try it in your agent + +Try Flint with your agent using our hosted MCP server. Clients that support +remote HTTP MCP servers include [GitHub Copilot in VS Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +(`MCP: Add Server` → `HTTP`) and [Claude](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) +(`Customize` → `Connectors` → `Add custom connector`). Point either client at: + +```text +https://flint.data-formulator.ai/mcp +``` + +Then ask your agent to find some public data and visualize it, for example: + +```text +Go to Yahoo Finance, find today's percentage change for the Magnificent Seven +tech stocks, and compare their performance with Flint in Economist style. +``` + +For a local MCP server (recommended for large datasets), check the +[MCP Server Guide](https://microsoft.github.io/flint-chart/#/mcp) for details. +

A wall of charts produced by Flint across its supported visualization backends.

@@ -53,11 +72,11 @@ This repo contains two main components: ## Updates +- **August 12, 2026** — Flint 0.5.1 extends visual themes to the Plotly backend + and adds Vega-Lite Calendar Heatmap. ([changelog](CHANGELOG.md)) - **August 5, 2026** — Flint 0.5.0 introduces a [formal theme specification](https://microsoft.github.io/flint-chart/#/themes) that allows designers and users to define a visual system once and apply it - consistently across an entire chart library. It includes ten presets: New - York Times, Economist, Swiss, Nature, McKinsey, Datawrapper, Power BI, Power - BI Light, Pop, and Cartoon. ([v0.5.0](https://github.com/microsoft/flint-chart/releases/tag/0.5.0)) + consistently across an entire chart library. ([v0.5.0](https://github.com/microsoft/flint-chart/releases/tag/0.5.0)) - **July 24, 2026** — Flint 0.4.0 adds 38 Plotly chart types and 18 native, editable Excel chart templates. ([v0.4.0](https://github.com/microsoft/flint-chart/releases/tag/0.4.0)) - **July 19, 2026** — Flint 0.3.0 adds dynamic chart widgets that switch chart diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index daed293c..af1a890f 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -268,6 +268,7 @@ properties"). Required channels are noted. | `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers`, `dodge` | | `"ECDF Plot"` | x, color, detail, column, row | x = measure; cumulative distribution (step line); prop `showPoints` | | `"Heatmap"` | x, y, color, column, row | color = the measure | +| `"Calendar Heatmap"` | x, color | x = date; color = daily value (summed per day); GitHub-style week × weekday grid | | `"Line Chart"` | x, y, color, strokeDash, detail, opacity, column, row | props `interpolate`, `showPoints` | | `"Sparkline"` | x, y, color, detail, row, column | x + y required; small-multiple mini trend lines, one per series (series from `color` or `detail`); props `interpolate`, `baseline`, `trendWidth` | | `"Bump Chart"` | x, y, color, detail, column, row | rank-over-time lines | @@ -321,7 +322,7 @@ type column. **Backend coverage.** Vega-Lite supports all of the above. Other backends support a subset (verify if targeting a non-VL backend): -- **ECharts** adds: `"Calendar Heatmap"`, `"Gauge"`, +- **ECharts** adds: `"Gauge"`, `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, diff --git a/docs/reference-plotly.md b/docs/reference-plotly.md index 7c408078..97abe2a3 100644 --- a/docs/reference-plotly.md +++ b/docs/reference-plotly.md @@ -31,7 +31,7 @@ The **Availability** column shows whether a parameter is `always` available or ` ### ![](chart-icon-scatter.svg) Scatter Plot -**Encoding channels:** `x`, `y`, `color`, `size`, `opacity`, `column`, `row` +**Encoding channels:** `x`, `y`, `color`, `size`, `shape`, `opacity`, `column`, `row` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| @@ -178,7 +178,7 @@ _No template-specific parameters._ ### ![](chart-icon-line.svg) Line Chart -**Encoding channels:** `x`, `y`, `color`, `opacity`, `column`, `row` +**Encoding channels:** `x`, `y`, `color`, `strokeDash`, `opacity`, `column`, `row` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| @@ -249,7 +249,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `filled` | choice | `true` (Filled (default)), `false` (Outline only) | — | always | Fill the enclosed radar area. | -| `fillOpacity` | number | 0.05 – 0.8 (step 0.05) | `0.3` | always | Fill opacity for the area or region. | +| `fillOpacity` | number | 0.05 – 0.8 (step 0.05) | `0.16` | always | Fill opacity for the area or region. | ### ![](chart-icon-rose.svg) Rose Chart diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md index c7c6f7f7..a65a9836 100644 --- a/docs/reference-vegalite.md +++ b/docs/reference-vegalite.md @@ -6,7 +6,7 @@ The Vega-Lite backend serves as Flint's reference implementation and offers the ## What this page covers -This reference lists the 35 chart types currently supported by the Vega-Lite backend, grouped into 6 categories. Each chart entry shows: +This reference lists the 36 chart types currently supported by the Vega-Lite backend, grouped into 6 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. @@ -411,6 +411,12 @@ The **Availability** column shows whether a parameter is `always` available or ` | `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | | `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | +### ![](chart-icon-calendar.svg) Calendar Heatmap + +**Encoding channels:** `x`, `color` + +_No template-specific parameters._ + ### ![](chart-icon-bar-table.svg) Bar Table **Encoding channels:** `y`, `x`, `color`, `column`, `row` diff --git a/docs/zh-CN/reference-plotly.md b/docs/zh-CN/reference-plotly.md index 93c104b2..c47696f0 100644 --- a/docs/zh-CN/reference-plotly.md +++ b/docs/zh-CN/reference-plotly.md @@ -27,7 +27,7 @@ Plotly 后端编译为 Plotly.js `{ data, layout }` 图形,并优先使用原 ### ![](chart-icon-scatter.svg) Scatter Plot -**编码通道:** `x`, `y`, `color`, `size`, `opacity`, `column`, `row` +**编码通道:** `x`, `y`, `color`, `size`, `shape`, `opacity`, `column`, `row` | 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 | |---|---|---|---|---|---| @@ -174,7 +174,7 @@ _无模板专用参数。_ ### ![](chart-icon-line.svg) Line Chart -**编码通道:** `x`, `y`, `color`, `opacity`, `column`, `row` +**编码通道:** `x`, `y`, `color`, `strokeDash`, `opacity`, `column`, `row` | 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 | |---|---|---|---|---|---| @@ -245,7 +245,7 @@ _无模板专用参数。_ | 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 | |---|---|---|---|---|---| | `filled` | choice | `true` (Filled (default)), `false` (Outline only) | — | always | 填充雷达图围成的区域。 | -| `fillOpacity` | number | 0.05 – 0.8 (step 0.05) | `0.3` | always | 区域填充不透明度。 | +| `fillOpacity` | number | 0.05 – 0.8 (step 0.05) | `0.16` | always | 区域填充不透明度。 | ### ![](chart-icon-rose.svg) Rose Chart diff --git a/package-lock.json b/package-lock.json index badbf71a..b7958def 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "puppeteer-core": "^25.4.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -208,6 +209,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -225,6 +227,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -242,6 +245,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -259,6 +263,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -276,6 +281,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -293,6 +299,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -310,6 +317,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -327,6 +335,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -344,6 +353,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -361,6 +371,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -378,6 +389,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -395,6 +407,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -412,6 +425,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -429,6 +443,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -446,6 +461,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -463,6 +479,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -480,6 +497,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -497,6 +515,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -514,6 +533,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -531,6 +551,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -548,6 +569,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -565,6 +587,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -582,6 +605,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -599,6 +623,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -616,6 +641,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -633,6 +659,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -689,9 +716,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha1-cj06MMBVjCJavJ/Eeac+FOJsPC8=", + "version": "1.1.18", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", "dev": true, "license": "MIT", "dependencies": { @@ -787,9 +814,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha1-cj06MMBVjCJavJ/Eeac+FOJsPC8=", + "version": "1.1.18", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", "dev": true, "license": "MIT", "dependencies": { @@ -873,18 +900,6 @@ "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@hono/node-server": { - "version": "2.0.10", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@hono/node-server/-/node-server-2.0.10.tgz", - "integrity": "sha1-zq0Nl2OasTjC9aOBKJMXY+mSr9U=", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@humanfs/core/-/core-0.19.2.tgz", @@ -1106,6 +1121,18 @@ } } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha1-mb2GJc266CZSoTXHE2PNYyL65IM=", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@napi-rs/canvas": { "version": "1.0.2", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas/-/canvas-1.0.2.tgz", @@ -1384,6 +1411,35 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@puppeteer/browsers": { + "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", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, "node_modules/@resvg/resvg-js": { "version": "2.6.2", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", @@ -3264,16 +3320,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha1-Gw5GlltHna1lr3N7SgJ5CgVJgzc=", + "version": "5.0.9", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha1-fHJDiAm1+lur9UGZofHCgaaYT88=", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -3484,6 +3540,23 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chromium-bidi": { + "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", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cliui/-/cliui-9.0.1.tgz", @@ -4019,6 +4092,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/devtools-protocol": { + "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": { "version": "1.0.1", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4120,6 +4200,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4313,9 +4394,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha1-cj06MMBVjCJavJ/Eeac+FOJsPC8=", + "version": "1.1.18", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", "dev": true, "license": "MIT", "dependencies": { @@ -4611,9 +4692,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha1-9pWkDwBqulBWMVc6ACHdshGUrRE=", + "version": "3.1.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha1-YQ83QZoDAnBDDOzWjXTj1NlnJdA=", "funding": [ { "type": "github", @@ -5147,9 +5228,9 @@ "license": "CC0-1.0" }, "node_modules/hono": { - "version": "4.12.30", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hono/-/hono-4.12.30.tgz", - "integrity": "sha1-TVewUcZmF7ADt7oxiRDLW3nEx6w=", + "version": "4.13.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hono/-/hono-4.13.0.tgz", + "integrity": "sha1-rBuhZQtP2cvlPhe+oFJfU8tlgT0=", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -5307,9 +5388,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha1-gF/BeLIMUYvUyFSLJP4wiS1/MgY=", + "version": "10.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha1-xZELxUG26uKHdl0eSEa+AwigXZM=", "license": "MIT", "engines": { "node": ">= 12" @@ -5451,9 +5532,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha1-0ZAFcqf3zwtfVAyDZz5gutNDZZI=", + "version": "4.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha1-ASFsAB1n9I4s1WDXCMevIQkKOEg=", "dev": true, "funding": [ { @@ -6940,6 +7021,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "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": { "version": "1.8.2", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mlly/-/mlly-1.8.2.tgz", @@ -6953,6 +7041,16 @@ "ufo": "^1.6.3" } }, + "node_modules/modern-tar": { + "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" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", @@ -6972,9 +7070,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha1-oE2OxLHxAAnS1TOUeu/kKTc3gWw=", + "version": "3.3.17", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha1-8cOqJTxSVHlWpSxQv/dUMW9hA3o=", "dev": true, "funding": [ { @@ -7289,9 +7387,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha1-Ra1c/eSZQI4gFHNII3VROBqSIDc=", + "version": "8.5.26", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha1-bnUTV4DH4Q3zQzvyJmxVLTXIxiA=", "dev": true, "funding": [ { @@ -7309,7 +7407,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7428,6 +7526,24 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "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", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/qs/-/qs-6.15.3.tgz", @@ -7552,9 +7668,9 @@ } }, "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha1-YSWdFZS5XBrOKZ7kxXRTVw8MIvE=", + "version": "7.18.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha1-p2xGzp5e2s1PUSidWnH3EwXbkVI=", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -7574,12 +7690,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha1-DRsTjikTkwWa1IHD4Q42Y4WpeKQ=", + "version": "7.18.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha1-AL8dDOi/F6bYMZSarNpyphIOSSY=", "license": "MIT", "dependencies": { - "react-router": "7.18.1" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" @@ -7591,7 +7707,7 @@ }, "node_modules/react-router/node_modules/cookie": { "version": "1.1.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-1.1.1.tgz", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-1.1.1.tgz", "integrity": "sha1-O7m9/II2nbnC9pyTycPOsxDIizw=", "license": "MIT", "engines": { @@ -7989,7 +8105,7 @@ }, "node_modules/set-cookie-parser": { "version": "2.7.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha1-zNCGc6muXS5E6iot4lCJ5nx+32g=", "license": "MIT" }, @@ -8496,621 +8612,826 @@ } } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha1-B7ggO/pwVsBlcFDjzNLDdzC6uPE=", + "node_modules/tsup/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha1-grdPkqp41yC3FBYpOfskjJCt31M=", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">= 0.8.0" + "node": ">=18" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha1-cdGnBTKTWC4WrJ8+uvGrmqSeVXA=", + "node_modules/tsup/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha1-WT4QoUULv8rGyzIfYfRoRTusIJ0=", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=18" } }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha1-L7Pt5p3/oK94ynxM51iWgGOLVt8=", + "node_modules/tsup/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha1-94y4oxIfwgWlMoWtskly2zhdGF0=", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "node_modules/tsup/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha1-RTFD0HMyYDPS0iyvnkjeS64nSwc=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=14.17" + "node": ">=18" } }, - "node_modules/typescript-eslint": { - "version": "8.64.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript-eslint/-/typescript-eslint-8.64.0.tgz", - "integrity": "sha1-SYTa5N6dyL+JKs9cOU0KKl8Iw+E=", + "node_modules/tsup/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha1-byMAD7m0C34Et9BgbAaTvQYy8yI=", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.64.0", - "@typescript-eslint/parser": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/utils": "8.64.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha1-eo+4dfzGOC0sfQs2knOLBQCpJGc=", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=", + "node_modules/tsup/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha1-Jzk90YuxJjxmOXnF8VduAMLQJL4=", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unified/-/unified-11.0.5.tgz", - "integrity": "sha1-9mZ3YQpcCp7pDKsrjU1mA3Am2eE=", "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/unist-util-find-after": { - "version": "5.0.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", - "integrity": "sha1-P8zBsIa1bzTIt5jh/5C1xURo6JY=", + "node_modules/tsup/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha1-IuRjj6UC0cACcHcyTJdkDjrfOmI=", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha1-0KP4by3Q23rNfYwkeAgLXGf5xqk=", + "node_modules/tsup/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha1-kiS45P6pJM4hlOPvw+muv4IhktY=", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha1-Z48gq1yhIHqX1+qKOINzyc+Ja+Q=", + "node_modules/tsup/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha1-uenQcMjBwESc8Ssg6sN9cKRZWSE=", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/unist-util-remove-position": { - "version": "5.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", - "integrity": "sha1-/qaKJWWECclGBAi8a0mRuWW1IWM=", + "node_modules/tsup/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha1-T10cJ1J9gXs1aEriFBnlfCvaCWY=", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha1-RJxuIaiA4IVb9aq63rOnQDFKusI=", + "node_modules/tsup/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha1-P4D7aWqpYFGpQEfzXIWwiyHDb54=", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha1-mioosKp2oV4NpwoIpYY6LwYOJGg=", + "node_modules/tsup/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha1-m+HywoIQsT67QVYiG7o1b+FnUgU=", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha1-d333+5hlLOFrS3zZmdChpA76OgI=", + "node_modules/tsup/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha1-SrXuZ6Pfy8tej9eIPa5uc1sRY7g=", + "cpu": [ + "mips64el" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "node_modules/tsup/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha1-2seMaJ9kmUWcQyHlwVAywSMH5+o=", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.8" + "node": ">=18" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha1-mxpSWVIlhZ5V9mnZKPiMbFfyp34=", + "node_modules/tsup/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha1-BQ99OzVcOpgwjpNbxNYyXakbACc=", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha1-sXS/plyytSZzLZ8qwKQIAnh28y0=", + "node_modules/tsup/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha1-1h9xXOYdQ/5YRK0Nj0Y/iMvk/vY=", + "cpu": [ + "s390x" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "node_modules/tsup/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha1-yo4apHj8ggkle/Osj3nE3CmC8yo=", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.8" + "node": ">=18" } }, - "node_modules/vega": { - "version": "6.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega/-/vega-6.2.0.tgz", - "integrity": "sha1-NMLeg7AOcB4EAClziybx7JkvMn8=", - "license": "BSD-3-Clause", - "dependencies": { - "vega-crossfilter": "~5.1.0", - "vega-dataflow": "~6.1.0", - "vega-encode": "~5.1.0", - "vega-event-selector": "~4.0.0", - "vega-expression": "~6.1.0", - "vega-force": "~5.1.0", - "vega-format": "~2.1.0", - "vega-functions": "~6.1.0", - "vega-geo": "~5.1.0", - "vega-hierarchy": "~5.1.0", - "vega-label": "~2.1.0", - "vega-loader": "~5.1.0", - "vega-parser": "~7.1.0", - "vega-projection": "~2.1.0", - "vega-regression": "~2.1.0", - "vega-runtime": "~7.1.0", - "vega-scale": "~8.1.0", - "vega-scenegraph": "~5.1.0", - "vega-statistics": "~2.0.0", - "vega-time": "~3.1.0", - "vega-transforms": "~5.1.0", - "vega-typings": "~2.1.0", - "vega-util": "~2.1.0", - "vega-view": "~6.1.0", - "vega-view-transforms": "~5.1.0", - "vega-voronoi": "~5.1.0", - "vega-wordcloud": "~5.1.0" - }, - "funding": { - "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + "node_modules/tsup/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha1-FlDywblI3us++Ujy/DBhRyPAlpA=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vega-canvas": { - "version": "2.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-canvas/-/vega-canvas-2.0.0.tgz", - "integrity": "sha1-Rwneto+bT9dHWVe+2Z8Ww428B7g=", - "license": "BSD-3-Clause" + "node_modules/tsup/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha1-ZXcqs0LEszGb8HBaIRBQqsG24yA=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/vega-crossfilter": { - "version": "5.1.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-crossfilter/-/vega-crossfilter-5.1.0.tgz", - "integrity": "sha1-9MVtngwxcFyuQc0ONavN7iDAxIM=", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "vega-dataflow": "^6.1.0", - "vega-util": "^2.1.0" + "node_modules/tsup/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha1-N+18+mZUnXlVhS/ON9DD3k5xXqE=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vega-dataflow": { - "version": "6.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-dataflow/-/vega-dataflow-6.1.0.tgz", - "integrity": "sha1-H8SOpru+AC1FoaSO7meuoJelfFU=", - "license": "BSD-3-Clause", - "dependencies": { - "vega-format": "^2.1.0", - "vega-loader": "^5.1.0", - "vega-util": "^2.1.0" + "node_modules/tsup/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha1-Ab89OFhV71DLM9t8S1L5V8NM0Xk=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vega-embed": { - "version": "7.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-embed/-/vega-embed-7.1.0.tgz", - "integrity": "sha1-QoQyuiz1uwlhwNNI956E7Kn8jPs=", - "license": "BSD-3-Clause", - "dependencies": { - "fast-json-patch": "^3.1.1", - "json-stringify-pretty-compact": "^4.0.0", - "semver": "^7.7.2", - "tslib": "^2.8.1", - "vega-interpreter": "^2.0.0", - "vega-schema-url-parser": "^3.0.2", - "vega-themes": "3.0.0", - "vega-tooltip": "1.0.0" - }, - "funding": { - "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" - }, - "peerDependencies": { - "vega": "*", - "vega-lite": "*" + "node_modules/tsup/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha1-bB+Us0CGWZqr2k6sj2OClLmHdBA=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vega-embed/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha1-YS7+TtI11Wfoq6Xypfq3AoCt6D8=", - "license": "0BSD" - }, - "node_modules/vega-encode": { - "version": "5.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-encode/-/vega-encode-5.1.0.tgz", - "integrity": "sha1-BfVriYgi4J35alyn8QF7n5ocTTs=", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-interpolate": "^3.0.1", - "vega-dataflow": "^6.1.0", - "vega-scale": "^8.1.0", - "vega-util": "^2.1.0" + "node_modules/tsup/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha1-Sw3ReuCmlB0tD9NakGOSUXBxqQ0=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vega-event-selector": { - "version": "4.0.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-event-selector/-/vega-event-selector-4.0.0.tgz", - "integrity": "sha1-Ql6fJnHoWKGkW0tqf8RSygsiq78=", - "license": "BSD-3-Clause" - }, - "node_modules/vega-expression": { - "version": "6.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-expression/-/vega-expression-6.1.0.tgz", - "integrity": "sha1-bONYo5ublTgGv/IA9vhPRBY8njg=", - "license": "BSD-3-Clause", - "dependencies": { - "@types/estree": "^1.0.8", - "vega-util": "^2.1.0" + "node_modules/tsup/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha1-NBk6tVZdb/aMqSisBL51ECzLLnc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vega-force": { - "version": "5.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-force/-/vega-force-5.1.0.tgz", - "integrity": "sha1-qnz47b4q47raBw80NWXfuEHlAak=", - "license": "BSD-3-Clause", - "dependencies": { - "d3-force": "^3.0.0", - "vega-dataflow": "^6.1.0", - "vega-util": "^2.1.0" + "node_modules/tsup/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha1-62fw5EglFdjBiU7eYxwyek2p/E0=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vega-format": { - "version": "2.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-format/-/vega-format-2.1.0.tgz", - "integrity": "sha1-RlLH7J+xt/+aLFDc1Jija6YUb9o=", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-format": "^3.1.0", - "d3-time-format": "^4.1.0", - "vega-time": "^3.1.0", - "vega-util": "^2.1.0" + "node_modules/tsup/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha1-j+MLMIi4m0hzw6bMh1l645IMCos=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vega-functions": { - "version": "6.1.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-functions/-/vega-functions-6.1.1.tgz", - "integrity": "sha1-XU6XRqrd4rO3DY2j5jOIktBc2dI=", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-color": "^3.1.0", - "d3-geo": "^3.1.1", - "vega-dataflow": "^6.1.0", - "vega-expression": "^6.1.0", - "vega-scale": "^8.1.0", - "vega-scenegraph": "^5.1.0", - "vega-selections": "^6.1.0", - "vega-statistics": "^2.0.0", - "vega-time": "^3.1.0", - "vega-util": "^2.1.0" + "node_modules/tsup/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha1-vK3OIrLz/XbyV+OmT4OmSYb+oR8=", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/vega-geo": { - "version": "5.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-geo/-/vega-geo-5.1.0.tgz", - "integrity": "sha1-2P5q6RKtJ80rHCH1RadMB9oJNYk=", - "license": "BSD-3-Clause", + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha1-B7ggO/pwVsBlcFDjzNLDdzC6uPE=", + "dev": true, + "license": "MIT", "dependencies": { - "d3-array": "^3.2.4", - "d3-color": "^3.1.0", - "d3-geo": "^3.1.1", - "vega-canvas": "^2.0.0", - "vega-dataflow": "^6.1.0", - "vega-projection": "^2.1.0", - "vega-statistics": "^2.0.0", - "vega-util": "^2.1.0" + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/vega-hierarchy": { - "version": "5.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-hierarchy/-/vega-hierarchy-5.1.0.tgz", - "integrity": "sha1-Qjdw3Ry0aENw8jpojcW22tE5nb8=", - "license": "BSD-3-Clause", + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha1-cdGnBTKTWC4WrJ8+uvGrmqSeVXA=", + "license": "MIT", "dependencies": { - "d3-hierarchy": "^3.1.2", - "vega-dataflow": "^6.1.0", - "vega-util": "^2.1.0" + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/vega-interpreter": { - "version": "2.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-interpreter/-/vega-interpreter-2.2.1.tgz", - "integrity": "sha1-lH2PKIelnRJK3diE0BsC9lc/uFE=", - "license": "BSD-3-Clause", - "dependencies": { - "vega-util": "^2.1.0" + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha1-L7Pt5p3/oK94ynxM51iWgGOLVt8=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/vega-label": { - "version": "2.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-label/-/vega-label-2.1.0.tgz", - "integrity": "sha1-vZd80U6bBi/OMVk6LbKBmqnvssk=", - "license": "BSD-3-Clause", - "dependencies": { - "vega-canvas": "^2.0.0", - "vega-dataflow": "^6.1.0", - "vega-scenegraph": "^5.1.0", - "vega-util": "^2.1.0" + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha1-ZeJGKsawrs+uG/rBpPMCcHDbq6o=", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" } }, - "node_modules/vega-lite": { - "version": "6.4.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-lite/-/vega-lite-6.4.3.tgz", - "integrity": "sha1-mXPTr+E5H7CIB7wlheEp4tr3o2s=", - "license": "BSD-3-Clause", + "node_modules/typescript-eslint": { + "version": "8.64.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript-eslint/-/typescript-eslint-8.64.0.tgz", + "integrity": "sha1-SYTa5N6dyL+JKs9cOU0KKl8Iw+E=", + "dev": true, + "license": "MIT", "dependencies": { - "json-stringify-pretty-compact": "~4.0.0", - "tslib": "~2.8.1", - "vega-event-selector": "~4.0.0", - "vega-expression": "~6.1.0", - "vega-util": "~2.1.0", - "yargs": "~18.0.0" - }, - "bin": { - "vl2pdf": "bin/vl2pdf", - "vl2png": "bin/vl2png", - "vl2svg": "bin/vl2svg", - "vl2vg": "bin/vl2vg" + "@typescript-eslint/eslint-plugin": "8.64.0", + "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0" }, "engines": { - "node": ">=20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "vega": "^6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/vega-lite/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha1-YS7+TtI11Wfoq6Xypfq3AoCt6D8=", - "license": "0BSD" + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha1-eo+4dfzGOC0sfQs2knOLBQCpJGc=", + "dev": true, + "license": "MIT" }, - "node_modules/vega-loader": { - "version": "5.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-loader/-/vega-loader-5.1.0.tgz", - "integrity": "sha1-aTePxNRujUVzrTCPdkZOZrAleeY=", - "license": "BSD-3-Clause", + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unified/-/unified-11.0.5.tgz", + "integrity": "sha1-9mZ3YQpcCp7pDKsrjU1mA3Am2eE=", + "license": "MIT", "dependencies": { - "d3-dsv": "^3.0.1", - "topojson-client": "^3.1.0", - "vega-format": "^2.1.0", - "vega-util": "^2.1.0" + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vega-parser": { - "version": "7.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-parser/-/vega-parser-7.1.0.tgz", - "integrity": "sha1-IO4OcKbs24yzTvFt7tSErWjECFA=", - "license": "BSD-3-Clause", + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha1-P8zBsIa1bzTIt5jh/5C1xURo6JY=", + "license": "MIT", "dependencies": { - "vega-dataflow": "^6.1.0", - "vega-event-selector": "^4.0.0", - "vega-functions": "^6.1.0", - "vega-scale": "^8.1.0", - "vega-util": "^2.1.0" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vega-projection": { - "version": "2.1.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-projection/-/vega-projection-2.1.0.tgz", - "integrity": "sha1-zkYpHveKdBjHVnkQMpbWL0mvrBQ=", - "license": "BSD-3-Clause", + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha1-0KP4by3Q23rNfYwkeAgLXGf5xqk=", + "license": "MIT", "dependencies": { - "d3-geo": "^3.1.1", - "d3-geo-projection": "^4.0.0", - "vega-scale": "^8.1.0" + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vega-regression": { - "version": "2.1.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-regression/-/vega-regression-2.1.0.tgz", - "integrity": "sha1-0/0QPpegruVa4qeO2BWI+13LngM=", - "license": "BSD-3-Clause", + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha1-Z48gq1yhIHqX1+qKOINzyc+Ja+Q=", + "license": "MIT", "dependencies": { - "d3-array": "^3.2.4", - "vega-dataflow": "^6.1.0", - "vega-statistics": "^2.0.0", - "vega-util": "^2.1.0" + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vega-runtime": { - "version": "7.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-runtime/-/vega-runtime-7.1.0.tgz", - "integrity": "sha1-GVnWFoY4+Fvc5NFXEXrKatH2n6w=", - "license": "BSD-3-Clause", + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha1-/qaKJWWECclGBAi8a0mRuWW1IWM=", + "license": "MIT", "dependencies": { - "vega-dataflow": "^6.1.0", - "vega-util": "^2.1.0" + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vega-scale": { - "version": "8.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-scale/-/vega-scale-8.1.0.tgz", - "integrity": "sha1-oGs6qNYK5GrY89ierg506z0SAOM=", - "license": "BSD-3-Clause", + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha1-RJxuIaiA4IVb9aq63rOnQDFKusI=", + "license": "MIT", "dependencies": { - "d3-array": "^3.2.4", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-scale-chromatic": "^3.1.0", - "vega-time": "^3.1.0", - "vega-util": "^2.1.0" + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vega-scenegraph": { + "node_modules/unist-util-visit": { "version": "5.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-scenegraph/-/vega-scenegraph-5.1.0.tgz", - "integrity": "sha1-OzwNhxeZ/oS8VjJW17nVS8LhM2g=", - "license": "BSD-3-Clause", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha1-mioosKp2oV4NpwoIpYY6LwYOJGg=", + "license": "MIT", "dependencies": { - "d3-path": "^3.1.0", - "d3-shape": "^3.2.0", - "vega-canvas": "^2.0.0", - "vega-loader": "^5.1.0", - "vega-scale": "^8.1.0", - "vega-util": "^2.1.0" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vega-schema-url-parser": { - "version": "3.0.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-schema-url-parser/-/vega-schema-url-parser-3.0.2.tgz", - "integrity": "sha1-lfCmrX/JNBr8BUlcGgdMYvpxxFY=", - "license": "BSD-3-Clause" - }, - "node_modules/vega-selections": { - "version": "6.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-selections/-/vega-selections-6.1.2.tgz", - "integrity": "sha1-Nkbbap/B1yWWm4tYQeXTM8Hw+AM=", - "license": "BSD-3-Clause", + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha1-d333+5hlLOFrS3zZmdChpA76OgI=", + "license": "MIT", "dependencies": { - "d3-array": "3.2.4", - "vega-expression": "^6.1.0", - "vega-util": "^2.1.0" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vega-statistics": { - "version": "2.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-statistics/-/vega-statistics-2.0.0.tgz", - "integrity": "sha1-nJY2wgaCrpjoiH+Pqw6CwkZqc2o=", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4" + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, - "node_modules/vega-themes": { - "version": "3.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-themes/-/vega-themes-3.0.0.tgz", - "integrity": "sha1-lvP3nDkvwwIOXRLvWP5k9RbTbh4=", + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha1-mxpSWVIlhZ5V9mnZKPiMbFfyp34=", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha1-sXS/plyytSZzLZ8qwKQIAnh28y0=", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vega": { + "version": "6.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega/-/vega-6.2.0.tgz", + "integrity": "sha1-NMLeg7AOcB4EAClziybx7JkvMn8=", + "license": "BSD-3-Clause", + "dependencies": { + "vega-crossfilter": "~5.1.0", + "vega-dataflow": "~6.1.0", + "vega-encode": "~5.1.0", + "vega-event-selector": "~4.0.0", + "vega-expression": "~6.1.0", + "vega-force": "~5.1.0", + "vega-format": "~2.1.0", + "vega-functions": "~6.1.0", + "vega-geo": "~5.1.0", + "vega-hierarchy": "~5.1.0", + "vega-label": "~2.1.0", + "vega-loader": "~5.1.0", + "vega-parser": "~7.1.0", + "vega-projection": "~2.1.0", + "vega-regression": "~2.1.0", + "vega-runtime": "~7.1.0", + "vega-scale": "~8.1.0", + "vega-scenegraph": "~5.1.0", + "vega-statistics": "~2.0.0", + "vega-time": "~3.1.0", + "vega-transforms": "~5.1.0", + "vega-typings": "~2.1.0", + "vega-util": "~2.1.0", + "vega-view": "~6.1.0", + "vega-view-transforms": "~5.1.0", + "vega-voronoi": "~5.1.0", + "vega-wordcloud": "~5.1.0" + }, + "funding": { + "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + } + }, + "node_modules/vega-canvas": { + "version": "2.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-canvas/-/vega-canvas-2.0.0.tgz", + "integrity": "sha1-Rwneto+bT9dHWVe+2Z8Ww428B7g=", + "license": "BSD-3-Clause" + }, + "node_modules/vega-crossfilter": { + "version": "5.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-crossfilter/-/vega-crossfilter-5.1.0.tgz", + "integrity": "sha1-9MVtngwxcFyuQc0ONavN7iDAxIM=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "vega-dataflow": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-dataflow": { + "version": "6.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-dataflow/-/vega-dataflow-6.1.0.tgz", + "integrity": "sha1-H8SOpru+AC1FoaSO7meuoJelfFU=", + "license": "BSD-3-Clause", + "dependencies": { + "vega-format": "^2.1.0", + "vega-loader": "^5.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-embed": { + "version": "7.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-embed/-/vega-embed-7.1.0.tgz", + "integrity": "sha1-QoQyuiz1uwlhwNNI956E7Kn8jPs=", "license": "BSD-3-Clause", + "dependencies": { + "fast-json-patch": "^3.1.1", + "json-stringify-pretty-compact": "^4.0.0", + "semver": "^7.7.2", + "tslib": "^2.8.1", + "vega-interpreter": "^2.0.0", + "vega-schema-url-parser": "^3.0.2", + "vega-themes": "3.0.0", + "vega-tooltip": "1.0.0" + }, "funding": { "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" }, @@ -9119,695 +9440,1452 @@ "vega-lite": "*" } }, - "node_modules/vega-time": { - "version": "3.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-time/-/vega-time-3.1.0.tgz", - "integrity": "sha1-TiDF1g4/foJ6M9spvUhV9AoK48s=", + "node_modules/vega-embed/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha1-YS7+TtI11Wfoq6Xypfq3AoCt6D8=", + "license": "0BSD" + }, + "node_modules/vega-encode": { + "version": "5.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-encode/-/vega-encode-5.1.0.tgz", + "integrity": "sha1-BfVriYgi4J35alyn8QF7n5ocTTs=", "license": "BSD-3-Clause", "dependencies": { "d3-array": "^3.2.4", - "d3-time": "^3.1.0", + "d3-interpolate": "^3.0.1", + "vega-dataflow": "^6.1.0", + "vega-scale": "^8.1.0", "vega-util": "^2.1.0" } }, - "node_modules/vega-tooltip": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-tooltip/-/vega-tooltip-1.0.0.tgz", - "integrity": "sha1-xoKcm831voU4UDB2ZcnyEyEIP/I=", + "node_modules/vega-event-selector": { + "version": "4.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-event-selector/-/vega-event-selector-4.0.0.tgz", + "integrity": "sha1-Ql6fJnHoWKGkW0tqf8RSygsiq78=", + "license": "BSD-3-Clause" + }, + "node_modules/vega-expression": { + "version": "6.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-expression/-/vega-expression-6.1.0.tgz", + "integrity": "sha1-bONYo5ublTgGv/IA9vhPRBY8njg=", "license": "BSD-3-Clause", "dependencies": { - "vega-util": "^2.0.0" + "@types/estree": "^1.0.8", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-force": { + "version": "5.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-force/-/vega-force-5.1.0.tgz", + "integrity": "sha1-qnz47b4q47raBw80NWXfuEHlAak=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-force": "^3.0.0", + "vega-dataflow": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-format": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-format/-/vega-format-2.1.0.tgz", + "integrity": "sha1-RlLH7J+xt/+aLFDc1Jija6YUb9o=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-format": "^3.1.0", + "d3-time-format": "^4.1.0", + "vega-time": "^3.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-functions": { + "version": "6.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-functions/-/vega-functions-6.1.1.tgz", + "integrity": "sha1-XU6XRqrd4rO3DY2j5jOIktBc2dI=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-geo": "^3.1.1", + "vega-dataflow": "^6.1.0", + "vega-expression": "^6.1.0", + "vega-scale": "^8.1.0", + "vega-scenegraph": "^5.1.0", + "vega-selections": "^6.1.0", + "vega-statistics": "^2.0.0", + "vega-time": "^3.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-geo": { + "version": "5.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-geo/-/vega-geo-5.1.0.tgz", + "integrity": "sha1-2P5q6RKtJ80rHCH1RadMB9oJNYk=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-geo": "^3.1.1", + "vega-canvas": "^2.0.0", + "vega-dataflow": "^6.1.0", + "vega-projection": "^2.1.0", + "vega-statistics": "^2.0.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-hierarchy": { + "version": "5.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-hierarchy/-/vega-hierarchy-5.1.0.tgz", + "integrity": "sha1-Qjdw3Ry0aENw8jpojcW22tE5nb8=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-hierarchy": "^3.1.2", + "vega-dataflow": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-interpreter": { + "version": "2.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-interpreter/-/vega-interpreter-2.2.1.tgz", + "integrity": "sha1-lH2PKIelnRJK3diE0BsC9lc/uFE=", + "license": "BSD-3-Clause", + "dependencies": { + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-label": { + "version": "2.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-label/-/vega-label-2.1.0.tgz", + "integrity": "sha1-vZd80U6bBi/OMVk6LbKBmqnvssk=", + "license": "BSD-3-Clause", + "dependencies": { + "vega-canvas": "^2.0.0", + "vega-dataflow": "^6.1.0", + "vega-scenegraph": "^5.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-lite": { + "version": "6.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-lite/-/vega-lite-6.4.3.tgz", + "integrity": "sha1-mXPTr+E5H7CIB7wlheEp4tr3o2s=", + "license": "BSD-3-Clause", + "dependencies": { + "json-stringify-pretty-compact": "~4.0.0", + "tslib": "~2.8.1", + "vega-event-selector": "~4.0.0", + "vega-expression": "~6.1.0", + "vega-util": "~2.1.0", + "yargs": "~18.0.0" + }, + "bin": { + "vl2pdf": "bin/vl2pdf", + "vl2png": "bin/vl2png", + "vl2svg": "bin/vl2svg", + "vl2vg": "bin/vl2vg" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + }, + "peerDependencies": { + "vega": "^6.0.0" + } + }, + "node_modules/vega-lite/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha1-YS7+TtI11Wfoq6Xypfq3AoCt6D8=", + "license": "0BSD" + }, + "node_modules/vega-loader": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-loader/-/vega-loader-5.1.0.tgz", + "integrity": "sha1-aTePxNRujUVzrTCPdkZOZrAleeY=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dsv": "^3.0.1", + "topojson-client": "^3.1.0", + "vega-format": "^2.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-parser": { + "version": "7.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-parser/-/vega-parser-7.1.0.tgz", + "integrity": "sha1-IO4OcKbs24yzTvFt7tSErWjECFA=", + "license": "BSD-3-Clause", + "dependencies": { + "vega-dataflow": "^6.1.0", + "vega-event-selector": "^4.0.0", + "vega-functions": "^6.1.0", + "vega-scale": "^8.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-projection": { + "version": "2.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-projection/-/vega-projection-2.1.0.tgz", + "integrity": "sha1-zkYpHveKdBjHVnkQMpbWL0mvrBQ=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-geo": "^3.1.1", + "d3-geo-projection": "^4.0.0", + "vega-scale": "^8.1.0" + } + }, + "node_modules/vega-regression": { + "version": "2.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-regression/-/vega-regression-2.1.0.tgz", + "integrity": "sha1-0/0QPpegruVa4qeO2BWI+13LngM=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "vega-dataflow": "^6.1.0", + "vega-statistics": "^2.0.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-runtime": { + "version": "7.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-runtime/-/vega-runtime-7.1.0.tgz", + "integrity": "sha1-GVnWFoY4+Fvc5NFXEXrKatH2n6w=", + "license": "BSD-3-Clause", + "dependencies": { + "vega-dataflow": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-scale": { + "version": "8.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-scale/-/vega-scale-8.1.0.tgz", + "integrity": "sha1-oGs6qNYK5GrY89ierg506z0SAOM=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.1.0", + "vega-time": "^3.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-scenegraph": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-scenegraph/-/vega-scenegraph-5.1.0.tgz", + "integrity": "sha1-OzwNhxeZ/oS8VjJW17nVS8LhM2g=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "^3.1.0", + "d3-shape": "^3.2.0", + "vega-canvas": "^2.0.0", + "vega-loader": "^5.1.0", + "vega-scale": "^8.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-schema-url-parser": { + "version": "3.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-schema-url-parser/-/vega-schema-url-parser-3.0.2.tgz", + "integrity": "sha1-lfCmrX/JNBr8BUlcGgdMYvpxxFY=", + "license": "BSD-3-Clause" + }, + "node_modules/vega-selections": { + "version": "6.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-selections/-/vega-selections-6.1.2.tgz", + "integrity": "sha1-Nkbbap/B1yWWm4tYQeXTM8Hw+AM=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "3.2.4", + "vega-expression": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-statistics": { + "version": "2.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-statistics/-/vega-statistics-2.0.0.tgz", + "integrity": "sha1-nJY2wgaCrpjoiH+Pqw6CwkZqc2o=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4" + } + }, + "node_modules/vega-themes": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-themes/-/vega-themes-3.0.0.tgz", + "integrity": "sha1-lvP3nDkvwwIOXRLvWP5k9RbTbh4=", + "license": "BSD-3-Clause", + "funding": { + "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + }, + "peerDependencies": { + "vega": "*", + "vega-lite": "*" + } + }, + "node_modules/vega-time": { + "version": "3.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-time/-/vega-time-3.1.0.tgz", + "integrity": "sha1-TiDF1g4/foJ6M9spvUhV9AoK48s=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-time": "^3.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-tooltip": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-tooltip/-/vega-tooltip-1.0.0.tgz", + "integrity": "sha1-xoKcm831voU4UDB2ZcnyEyEIP/I=", + "license": "BSD-3-Clause", + "dependencies": { + "vega-util": "^2.0.0" + }, + "funding": { + "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + } + }, + "node_modules/vega-transforms": { + "version": "5.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-transforms/-/vega-transforms-5.1.0.tgz", + "integrity": "sha1-TpXNfEdzqlYJKNEDhaDTPqJ0jKo=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "vega-dataflow": "^6.1.0", + "vega-statistics": "^2.0.0", + "vega-time": "^3.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-typings": { + "version": "2.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-typings/-/vega-typings-2.1.0.tgz", + "integrity": "sha1-HB/lSMDwCZeCAkat4NPYE7h7/XY=", + "license": "BSD-3-Clause", + "dependencies": { + "@types/geojson": "7946.0.16", + "vega-event-selector": "^4.0.0", + "vega-expression": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-util": { + "version": "2.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-util/-/vega-util-2.1.1.tgz", + "integrity": "sha1-0fSU4C/dK2oWeadAuFJUuGTN1Zc=", + "license": "BSD-3-Clause" + }, + "node_modules/vega-view": { + "version": "6.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-view/-/vega-view-6.1.0.tgz", + "integrity": "sha1-VZb3jF68qNy1f+ykD9McuCZf0E4=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-timer": "^3.0.1", + "vega-dataflow": "^6.1.0", + "vega-format": "^2.1.0", + "vega-functions": "^6.1.0", + "vega-runtime": "^7.1.0", + "vega-scenegraph": "^5.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-view-transforms": { + "version": "5.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-view-transforms/-/vega-view-transforms-5.1.0.tgz", + "integrity": "sha1-HzH3Xvz5mziWnnUAQ625Ivzsbz4=", + "license": "BSD-3-Clause", + "dependencies": { + "vega-dataflow": "^6.1.0", + "vega-scenegraph": "^5.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-voronoi": { + "version": "5.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-voronoi/-/vega-voronoi-5.1.0.tgz", + "integrity": "sha1-kpVrnXjwbjkYlw/ITQaXTiS59S8=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-delaunay": "^6.0.4", + "vega-dataflow": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-wordcloud": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-wordcloud/-/vega-wordcloud-5.1.0.tgz", + "integrity": "sha1-eqjcv2yDsZP+cftkEL4VrSxyheY=", + "license": "BSD-3-Clause", + "dependencies": { + "vega-canvas": "^2.0.0", + "vega-dataflow": "^6.1.0", + "vega-scale": "^8.1.0", + "vega-statistics": "^2.0.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha1-NlKrHEllMYUr9VprrFevmB68OKs=", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha1-y56s0g8rZCbRlFHg6vo9CoRiJcM=", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha1-h7RN3de3DwZBwuPtCGS6c+LqjfQ=", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite/-/vite-8.1.5.tgz", + "integrity": "sha1-zP/OPuSHsYhiI7JOuye5FnSCfTA=", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha1-6FmupMDEt0/LumuvUn6I8q0J3mk=", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha1-fpKF7+JksRZwULejp/80eI4bevw=", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha1-rkW7Lt69qUxw9OqJfg8SQ+Rw23E=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha1-exfIxog9TouGrIq6edOeiA+IacU=", + "license": "MIT" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha1-EBD/fGUOzLJZLOvur5obJT/UBpI=", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha1-9Ru3HCYG6Q49VydgfHKLJdYXtYs=", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz", + "integrity": "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha1-o/aalxB/SUs83Dvd3Yg6fWXOvwQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" }, - "funding": { - "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" } }, - "node_modules/vega-transforms": { - "version": "5.1.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-transforms/-/vega-transforms-5.1.0.tgz", - "integrity": "sha1-TpXNfEdzqlYJKNEDhaDTPqJ0jKo=", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "vega-dataflow": "^6.1.0", - "vega-statistics": "^2.0.0", - "vega-time": "^3.1.0", - "vega-util": "^2.1.0" + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha1-0sRcbdT7zmIaZvE2y+Mor9BBCzQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/vega-typings": { - "version": "2.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-typings/-/vega-typings-2.1.0.tgz", - "integrity": "sha1-HB/lSMDwCZeCAkat4NPYE7h7/XY=", - "license": "BSD-3-Clause", + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha1-lWgy3qlJQwbm0gnrhxZDu4c9fJg=", + "license": "MIT", "dependencies": { - "@types/geojson": "7946.0.16", - "vega-event-selector": "^4.0.0", - "vega-expression": "^6.1.0", - "vega-util": "^2.1.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/vega-util": { - "version": "2.1.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-util/-/vega-util-2.1.1.tgz", - "integrity": "sha1-0fSU4C/dK2oWeadAuFJUuGTN1Zc=", - "license": "BSD-3-Clause" + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "license": "ISC" }, - "node_modules/vega-view": { - "version": "6.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-view/-/vega-view-6.1.0.tgz", - "integrity": "sha1-VZb3jF68qNy1f+ykD9McuCZf0E4=", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-timer": "^3.0.1", - "vega-dataflow": "^6.1.0", - "vega-format": "^2.1.0", - "vega-functions": "^6.1.0", - "vega-runtime": "^7.1.0", - "vega-scenegraph": "^5.1.0", - "vega-util": "^2.1.0" + "node_modules/ws": { + "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" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, - "node_modules/vega-view-transforms": { - "version": "5.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-view-transforms/-/vega-view-transforms-5.1.0.tgz", - "integrity": "sha1-HzH3Xvz5mziWnnUAQ625Ivzsbz4=", - "license": "BSD-3-Clause", - "dependencies": { - "vega-dataflow": "^6.1.0", - "vega-scenegraph": "^5.1.0", - "vega-util": "^2.1.0" + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha1-f0k00PfKjFb5UxSTndzS3ZHOHVU=", + "license": "ISC", + "engines": { + "node": ">=10" } }, - "node_modules/vega-voronoi": { - "version": "5.1.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-voronoi/-/vega-voronoi-5.1.0.tgz", - "integrity": "sha1-kpVrnXjwbjkYlw/ITQaXTiS59S8=", - "license": "BSD-3-Clause", + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha1-bIQlmAYnOnRrCfV5CHtoo8LSW9E=", + "license": "MIT", "dependencies": { - "d3-delaunay": "^6.0.4", - "vega-dataflow": "^6.1.0", - "vega-util": "^2.1.0" + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/vega-wordcloud": { - "version": "5.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vega-wordcloud/-/vega-wordcloud-5.1.0.tgz", - "integrity": "sha1-eqjcv2yDsZP+cftkEL4VrSxyheY=", - "license": "BSD-3-Clause", - "dependencies": { - "vega-canvas": "^2.0.0", - "vega-dataflow": "^6.1.0", - "vega-scale": "^8.1.0", - "vega-statistics": "^2.0.0", - "vega-util": "^2.1.0" + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha1-h7gglAUbBWdxc0bs0A/RSASzV8g=", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha1-NlKrHEllMYUr9VprrFevmB68OKs=", + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha1-ApTrPe4FAo0x7hpfosVWpqrxChs=", + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" + "engines": { + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/vfile-location": { - "version": "5.0.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vfile-location/-/vfile-location-5.0.3.tgz", - "integrity": "sha1-y56s0g8rZCbRlFHg6vo9CoRiJcM=", + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zod/-/zod-3.25.76.tgz", + "integrity": "sha1-JoQcP2/SKmonYOfMtxkXl2hHHjQ=", "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile": "^6.0.0" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha1-h7RN3de3DwZBwuPtCGS6c+LqjfQ=", - "license": "MIT", + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha1-P6eZp7rdVUVBRy+2WEP9xGCy5ao=", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha1-w+8G5kJtXCiGW3GDA1aA1oXgATY=", + "license": "BSD-3-Clause", "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "tslib": "2.3.0" } }, - "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite/-/vite-8.1.5.tgz", - "integrity": "sha1-zP/OPuSHsYhiI7JOuye5FnSCfTA=", - "dev": true, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha1-yCfUsKy3b8PmhaTG7CkC1RBw6dc=", "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/flint-js": { + "name": "flint-chart", + "version": "0.5.1", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.14.10", + "@typescript-eslint/eslint-plugin": "^8.16.0", + "@typescript-eslint/parser": "^8.16.0", + "eslint": "^9.15.0", + "eslint-plugin-unused-imports": "^4.4.1", + "prettier": "^3.3.0", + "rimraf": "^6.0.1", + "tsup": "^8.3.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.16.0", + "vitest": "^4.1.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "chart.js": "^4.0.0", + "echarts": "^5.0.0 || ^6.0.0", + "plotly.js": "^2.0.0 || ^3.0.0", + "vega": "^5.0.0 || ^6.0.0", + "vega-lite": "^5.0.0 || ^6.0.0" }, "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { + "chart.js": { "optional": true }, - "sugarss": { + "echarts": { "optional": true }, - "terser": { + "plotly.js": { "optional": true }, - "tsx": { + "vega": { "optional": true }, - "yaml": { + "vega-lite": { "optional": true } } }, - "node_modules/vite-plugin-singlefile": { - "version": "2.3.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", - "integrity": "sha1-6FmupMDEt0/LumuvUn6I8q0J3mk=", - "dev": true, + "packages/flint-mcp": { + "name": "flint-chart-mcp", + "version": "0.5.1", "license": "MIT", "dependencies": { - "micromatch": "^4.0.8" + "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/sdk": "^1.29.0", + "@napi-rs/canvas": "^1.0.0", + "@resvg/resvg-js": "^2.6.2", + "chart.js": "^4.4.0", + "echarts": "^6.0.0", + "flint-chart": "^0.5.1", + "vega": "^6.0.0", + "vega-interpreter": "^2.2.1", + "vega-lite": "^6.0.0", + "zod": "^3.25.1" }, - "engines": { - "node": ">18.0.0" + "bin": { + "flint-chart-mcp": "dist/cli.js" }, - "peerDependencies": { - "rollup": "^4.59.0", - "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + "devDependencies": { + "@types/node": "^20.14.10", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "rimraf": "^6.0.1", + "tsup": "^8.3.0", + "typescript": "^5.6.0", + "vite": "^8.1.0", + "vite-plugin-singlefile": "^2.3.3", + "vitest": "^4.1.8" }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "engines": { + "node": ">=18" } }, - "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha1-fpKF7+JksRZwULejp/80eI4bevw=", + "packages/flint-mcp/node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/react/-/react-19.2.17.tgz", + "integrity": "sha1-3MrDZbqg8XNOwnD/S1HIlGXo3H8=", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, + "csstype": "^3.2.2" + } + }, + "packages/flint-mcp/node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha1-weMF0VpSo+UI1U3Kdw0gLLY6vyw=", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "packages/flint-mcp/node_modules/react": { + "version": "19.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react/-/react-19.2.7.tgz", + "integrity": "sha1-H0ehv8BvjsiFdSxvSvFDaan4Jgs=", + "dev": true, + "license": "MIT", "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "node": ">=0.10.0" + } + }, + "packages/flint-mcp/node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha1-BFDcmundv/du8ZZAHNi4x/tGbMw=", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "react": "^19.2.7" + } + }, + "packages/flint-mcp/node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha1-DE74LWfR5cHjWej8dtOofwRf5b0=", + "dev": true, + "license": "MIT" + }, + "site": { + "name": "flint-chart-site", + "version": "0.0.0", + "dependencies": { + "@codemirror/lang-json": "^6.0.1", + "@fontsource-variable/inter": "^5.2.8", + "@uiw/react-codemirror": "^4.23.0", + "chart.js": "^4.5.1", + "echarts": "^6.0.0", + "flint-chart": "*", + "i18next": "^26.3.6", + "katex": "^0.17.0", + "lucide-react": "^1.27.0", + "plotly.js-dist-min": "^2.35.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.10", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.18.1", + "react-syntax-highlighter": "^16.1.1", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "vega": "^6.0.0", + "vega-embed": "^7.1.0", + "vega-lite": "^6.4.1" }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@types/react-syntax-highlighter": "^15.5.13", + "@vitejs/plugin-react-swc": "^3.7.0", + "typescript": "^5.6.0", + "vite": "^7.3.5" + } + }, + "site/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha1-grdPkqp41yC3FBYpOfskjJCt31M=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha1-rkW7Lt69qUxw9OqJfg8SQ+Rw23E=", + "site/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha1-WT4QoUULv8rGyzIfYfRoRTusIJ0=", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { "node": ">=18" } }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=", + "site/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha1-94y4oxIfwgWlMoWtskly2zhdGF0=", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha1-exfIxog9TouGrIq6edOeiA+IacU=", - "license": "MIT" + "site/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha1-RTFD0HMyYDPS0iyvnkjeS64nSwc=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/web-namespaces/-/web-namespaces-2.0.1.tgz", - "integrity": "sha1-EBD/fGUOzLJZLOvur5obJT/UBpI=", + "site/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha1-byMAD7m0C34Et9BgbAaTvQYy8yI=", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz", - "integrity": "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, + "site/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha1-Jzk90YuxJjxmOXnF8VduAMLQJL4=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha1-o/aalxB/SUs83Dvd3Yg6fWXOvwQ=", + "site/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha1-IuRjj6UC0cACcHcyTJdkDjrfOmI=", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha1-0sRcbdT7zmIaZvE2y+Mor9BBCzQ=", + "site/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha1-kiS45P6pJM4hlOPvw+muv4IhktY=", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha1-lWgy3qlJQwbm0gnrhxZDu4c9fJg=", + "site/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha1-uenQcMjBwESc8Ssg6sN9cKRZWSE=", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "license": "ISC" + "site/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha1-T10cJ1J9gXs1aEriFBnlfCvaCWY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha1-f0k00PfKjFb5UxSTndzS3ZHOHVU=", - "license": "ISC", + "site/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha1-P4D7aWqpYFGpQEfzXIWwiyHDb54=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha1-bIQlmAYnOnRrCfV5CHtoo8LSW9E=", + "site/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha1-m+HywoIQsT67QVYiG7o1b+FnUgU=", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=18" } }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha1-h7gglAUbBWdxc0bs0A/RSASzV8g=", - "license": "ISC", + "site/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha1-SrXuZ6Pfy8tej9eIPa5uc1sRY7g=", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=18" } }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha1-ApTrPe4FAo0x7hpfosVWpqrxChs=", + "site/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha1-2seMaJ9kmUWcQyHlwVAywSMH5+o=", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zod/-/zod-3.25.76.tgz", - "integrity": "sha1-JoQcP2/SKmonYOfMtxkXl2hHHjQ=", + "site/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha1-BQ99OzVcOpgwjpNbxNYyXakbACc=", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha1-P6eZp7rdVUVBRy+2WEP9xGCy5ao=", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" + "site/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha1-1h9xXOYdQ/5YRK0Nj0Y/iMvk/vY=", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/zrender": { - "version": "6.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zrender/-/zrender-6.1.0.tgz", - "integrity": "sha1-w+8G5kJtXCiGW3GDA1aA1oXgATY=", - "license": "BSD-3-Clause", - "dependencies": { - "tslib": "2.3.0" + "site/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha1-yo4apHj8ggkle/Osj3nE3CmC8yo=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha1-yCfUsKy3b8PmhaTG7CkC1RBw6dc=", + "site/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha1-FlDywblI3us++Ujy/DBhRyPAlpA=", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "packages/flint-js": { - "name": "flint-chart", - "version": "0.5.0", + "site/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha1-ZXcqs0LEszGb8HBaIRBQqsG24yA=", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "devDependencies": { - "@types/node": "^20.14.10", - "@typescript-eslint/eslint-plugin": "^8.16.0", - "@typescript-eslint/parser": "^8.16.0", - "eslint": "^9.15.0", - "eslint-plugin-unused-imports": "^4.4.1", - "prettier": "^3.3.0", - "rimraf": "^6.0.1", - "tsup": "^8.3.0", - "typescript": "^5.6.0", - "typescript-eslint": "^8.16.0", - "vitest": "^4.1.8" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { "node": ">=18" - }, - "peerDependencies": { - "chart.js": "^4.0.0", - "echarts": "^5.0.0 || ^6.0.0", - "plotly.js": "^2.0.0 || ^3.0.0", - "vega": "^5.0.0 || ^6.0.0", - "vega-lite": "^5.0.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "chart.js": { - "optional": true - }, - "echarts": { - "optional": true - }, - "plotly.js": { - "optional": true - }, - "vega": { - "optional": true - }, - "vega-lite": { - "optional": true - } } }, - "packages/flint-mcp": { - "name": "flint-chart-mcp", - "version": "0.5.0", + "site/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha1-N+18+mZUnXlVhS/ON9DD3k5xXqE=", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.29.0", - "@napi-rs/canvas": "^1.0.0", - "@resvg/resvg-js": "^2.6.2", - "chart.js": "^4.4.0", - "echarts": "^6.0.0", - "flint-chart": "^0.5.0", - "vega": "^6.0.0", - "vega-interpreter": "^2.2.1", - "vega-lite": "^6.0.0", - "zod": "^3.25.1" - }, - "bin": { - "flint-chart-mcp": "dist/cli.js" - }, - "devDependencies": { - "@types/node": "^20.14.10", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "rimraf": "^6.0.1", - "tsup": "^8.3.0", - "typescript": "^5.6.0", - "vite": "^8.1.0", - "vite-plugin-singlefile": "^2.3.3", - "vitest": "^4.1.8" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { "node": ">=18" } }, - "packages/flint-mcp/node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/react/-/react-19.2.17.tgz", - "integrity": "sha1-3MrDZbqg8XNOwnD/S1HIlGXo3H8=", + "site/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha1-Ab89OFhV71DLM9t8S1L5V8NM0Xk=", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "packages/flint-mcp/node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha1-weMF0VpSo+UI1U3Kdw0gLLY6vyw=", + "site/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha1-bB+Us0CGWZqr2k6sj2OClLmHdBA=", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "packages/flint-mcp/node_modules/react": { - "version": "19.2.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react/-/react-19.2.7.tgz", - "integrity": "sha1-H0ehv8BvjsiFdSxvSvFDaan4Jgs=", + "site/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha1-Sw3ReuCmlB0tD9NakGOSUXBxqQ0=", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "packages/flint-mcp/node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha1-BFDcmundv/du8ZZAHNi4x/tGbMw=", + "site/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha1-NBk6tVZdb/aMqSisBL51ECzLLnc=", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "packages/flint-mcp/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha1-DE74LWfR5cHjWej8dtOofwRf5b0=", + "site/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha1-62fw5EglFdjBiU7eYxwyek2p/E0=", + "cpu": [ + "ia32" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "site": { - "name": "flint-chart-site", - "version": "0.0.0", - "dependencies": { - "@codemirror/lang-json": "^6.0.1", - "@fontsource-variable/inter": "^5.2.8", - "@uiw/react-codemirror": "^4.23.0", - "chart.js": "^4.5.1", - "echarts": "^6.0.0", - "flint-chart": "*", - "i18next": "^26.3.6", - "katex": "^0.17.0", - "lucide-react": "^1.27.0", - "plotly.js-dist-min": "^2.35.2", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-i18next": "^17.0.10", - "react-markdown": "^10.1.0", - "react-router-dom": "^7.18.1", - "react-syntax-highlighter": "^16.1.1", - "rehype-katex": "^7.0.1", - "remark-gfm": "^4.0.1", - "remark-math": "^6.0.0", - "vega": "^6.0.0", - "vega-embed": "^7.1.0", - "vega-lite": "^6.4.1" - }, - "devDependencies": { - "@types/react": "^18.3.0", - "@types/react-dom": "^18.3.0", - "@types/react-syntax-highlighter": "^15.5.13", - "@vitejs/plugin-react-swc": "^3.7.0", - "typescript": "^5.6.0", - "vite": "^7.3.5" + "site/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha1-j+MLMIi4m0hzw6bMh1l645IMCos=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "site/node_modules/@rolldown/pluginutils": { @@ -9831,6 +10909,48 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, + "site/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha1-vK3OIrLz/XbyV+OmT4OmSYb+oR8=", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "site/node_modules/vite": { "version": "7.3.5", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite/-/vite-7.3.5.tgz", diff --git a/package.json b/package.json index 531653b6..191fc32b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "flint-chart-monorepo", "private": true, - "description": "Flint \u2014 semantic chart compiler for AI agents (JS + Python)", + "description": "Flint — semantic chart compiler for AI agents (JS + Python)", "workspaces": [ "packages/flint-js", "packages/flint-mcp", @@ -36,6 +36,7 @@ "devDependencies": { "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "puppeteer-core": "^25.4.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -48,4 +49,4 @@ "js-yaml": "^4.2.0", "@hono/node-server": "^2.0.5" } -} \ No newline at end of file +} diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index 167841f7..e74f6b47 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -1,6 +1,6 @@ { "name": "flint-chart", - "version": "0.5.0", + "version": "0.5.1", "description": "Semantic-level visualization library that compiles data + semantic types for Vega-Lite, ECharts, Chart.js, Plotly, and Excel.", "keywords": [ "visualization", diff --git a/packages/flint-js/src/chartjs/assemble.ts b/packages/flint-js/src/chartjs/assemble.ts index 14b12948..85a16571 100644 --- a/packages/flint-js/src/chartjs/assemble.ts +++ b/packages/flint-js/src/chartjs/assemble.ts @@ -179,6 +179,8 @@ export function assembleChartjs(input: ChartAssemblyInput): any { // Chart.js fills its canvas natively — a wider default band size // matches its generous category spacing behavior. defaultBandSize: 30, + // Chart.js categories natively fill the available chart area. + bandStepFit: 1, // Chart.js has no default maxBarThickness, so sparse bars fill the // canvas. Allow bands to expand past the base to match, capped so a // couple of bars don't span the whole canvas. @@ -193,6 +195,8 @@ export function assembleChartjs(input: ChartAssemblyInput): any { // Resolve the optional canvasSize ceiling into per-dimension stretch caps // (βx, βy). Falls back to maxStretch when no ceiling is set. 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 { diff --git a/packages/flint-js/src/core/compute-layout.ts b/packages/flint-js/src/core/compute-layout.ts index 30ef36fc..f5aec42b 100644 --- a/packages/flint-js/src/core/compute-layout.ts +++ b/packages/flint-js/src/core/compute-layout.ts @@ -72,6 +72,13 @@ const VL_SHORT_DISCRETE_LABEL_MAX_LEN = 8; /** Approximate width (px) of one label character at the given font size. */ const APPROX_CHAR_WIDTH_RATIO = 0.62; +/** + * How wide one category may grow when a sparse axis fits itself to the room it + * has. Past this a "band" stops reading as a mark and starts reading as a + * panel, however much canvas is going spare. + */ +const SPARSE_FIT_BAND_CEILING = 100; + /** Distinct label strings for a discrete axis field, plus derived stats. */ interface DiscreteLabelStats { count: number; @@ -288,6 +295,7 @@ export function computeLayout( minStep: minStepVal = 6, minSubplotSize: minSubplotVal = 60, stepPadding: stepPaddingVal = 0.1, + bandStepFit: bandStepFitVal = 0, maintainContinuousAxisRatio = false, continuousMarkCrossSection, facetAspectRatioResistance = 0, @@ -310,11 +318,6 @@ export function computeLayout( const sizeRatio = Math.max(defaultChartWidth, defaultChartHeight) / baseRefSize; const baseBandSize = options.defaultBandSize ?? 20; const defaultStepSize = Math.round(baseBandSize * Math.max(1, sizeRatio)); - // Sparse-expansion ceiling: a band may grow past its base size to fill a - // wide plot, but never past maxStepSize. Defaults to the base band, so a - // backend that doesn't opt in keeps the old "cap at base" behavior. - const maxBandSize = Math.max(baseBandSize, options.maxBandSize ?? baseBandSize); - const maxStepSize = Math.round(maxBandSize * Math.max(1, sizeRatio)); const isDiscreteType = (t: string | undefined) => t === 'nominal' || t === 'ordinal'; @@ -328,6 +331,22 @@ export function computeLayout( const axisFlags = declaration.axisFlags || {}; const xBanded = axisFlags.x?.banded ?? false; const yBanded = axisFlags.y?.banded ?? false; + // Two banded axes describe cells, not freely thickened bars. Their shared + // cell-size resolver owns both steps, so sparse-axis fit must stay out. + const effectiveBandStepFit = xBanded && yBanded ? 0 : bandStepFitVal; + // Sparse-expansion ceiling: a band may grow past its base size to fill a + // wide plot, but never past maxStepSize. Defaults to the base band, so a + // backend that doesn't opt in keeps the old "cap at base" behavior. + const maxBandSize = options.maxBandSize == null + ? (effectiveBandStepFit > 0 ? Math.max(baseBandSize, SPARSE_FIT_BAND_CEILING) : baseBandSize) + : Math.max(baseBandSize, options.maxBandSize); + // A sparse-fit ceiling is an absolute readability limit, not a share of the + // canvas: scaling it with the plot is what turns three categories into + // three slabs. A template that already asks for a wide band (a slopegraph's + // two columns) keeps its own step; a caller-stated ceiling still scales. + const maxStepSize = options.maxBandSize == null && effectiveBandStepFit > 0 + ? Math.max(defaultStepSize, SPARSE_FIT_BAND_CEILING) + : Math.round(maxBandSize * Math.max(1, sizeRatio)); const nominalCount: Record = { x: 0, y: 0, column: 0, row: 0, group: 0, @@ -805,12 +824,18 @@ export function computeLayout( elasticity: elasticityVal, maxStretch: maxStretchX, defaultStepSize, + bandStepFit: effectiveBandStepFit, + bandStepFitCapacity: Math.max(1, + (options.bandStepFitCapacityX ?? defaultChartWidth) / defaultChartWidth), minStep: minStepVal, }; const elasticParamsY: ElasticStretchParams = { elasticity: elasticityVal, maxStretch: maxStretchY, defaultStepSize, + bandStepFit: effectiveBandStepFit, + bandStepFitCapacity: Math.max(1, + (options.bandStepFitCapacityY ?? defaultChartHeight) / defaultChartHeight), minStep: minStepVal, }; @@ -832,9 +857,10 @@ export function computeLayout( const itemsPerGroup = nominalCount.group; const defaultGroupStep = itemsPerGroup * maxStepSize; const minGroupStep = Math.max(Math.ceil(MIN_GROUP_GAP_PX / stepPaddingVal), 2 * itemsPerGroup); - const groupElasticX = options.groupBandFillsLanes - ? { ...elasticParamsX, defaultStepSize: elasticParamsX.defaultStepSize * itemsPerGroup } - : elasticParamsX; + const groupElasticX = { + ...elasticParamsX, + defaultStepSize: elasticParamsX.defaultStepSize * itemsPerGroup, + }; const groupAxis = computeAxisStep(nominalCount.x, 0, subplotWidth, groupElasticX); const groupStep = Math.max(minGroupStep, Math.min(defaultGroupStep, groupAxis.step)); xStepSize = groupStep; @@ -851,9 +877,10 @@ export function computeLayout( const itemsPerGroup = nominalCount.group; const defaultGroupStep = itemsPerGroup * maxStepSize; const minGroupStep = Math.max(Math.ceil(MIN_GROUP_GAP_PX / stepPaddingVal), 2 * itemsPerGroup); - const groupElasticY = options.groupBandFillsLanes - ? { ...elasticParamsY, defaultStepSize: elasticParamsY.defaultStepSize * itemsPerGroup } - : elasticParamsY; + const groupElasticY = { + ...elasticParamsY, + defaultStepSize: elasticParamsY.defaultStepSize * itemsPerGroup, + }; const groupAxis = computeAxisStep(nominalCount.y, 0, subplotHeight, groupElasticY); const groupStep = Math.max(minGroupStep, Math.min(defaultGroupStep, groupAxis.step)); yStepSize = groupStep; @@ -976,7 +1003,12 @@ export function computeLayout( && !bothDiscreteConnected) { const capX = Math.floor(maxSubplotW / xTotalNominalCount); const capY = Math.floor(maxSubplotH / yTotalNominalCount); - const generous = Math.round(CELL_BAND_SIZE * Math.max(1, sizeRatio)); + // A grid cell is a two-dimensional reading target, not a bar whose + // thickness should grow with the chart footprint. Scaling this 28px + // target by a house's base canvas turned McKinsey's 440px chart into + // 41px tiles, even though the measured design uses roughly 26px cells. + // Cardinality may still shrink cells through capX/capY below. + const generous = CELL_BAND_SIZE; // The square is the narrower of the two steps — that one already fits — // grown to the generous size if there is room for it on both axes. const wanted = Math.max(generous, Math.min(xStepSize, yStepSize)); diff --git a/packages/flint-js/src/core/decisions.ts b/packages/flint-js/src/core/decisions.ts index a6b9e6c4..b2803e47 100644 --- a/packages/flint-js/src/core/decisions.ts +++ b/packages/flint-js/src/core/decisions.ts @@ -552,6 +552,10 @@ export interface ElasticStretchParams { maxStretch: number; /** Default step size in px per discrete item */ defaultStepSize: number; + /** Blend from base step (0) to available span per item (1). */ + bandStepFit?: number; + /** Explicit available-span multiplier; does not include fallback stretch. */ + bandStepFitCapacity?: number; /** Minimum pixels per discrete item (default: 6) */ minStep: number; } @@ -622,13 +626,33 @@ export function computeAxisStep( baseDimension: number, params: ElasticStretchParams, ): AxisStepDecision { - if (nominalCount > 0) { - const { budget } = computeElasticBudget(nominalCount, baseDimension, params); - return { step: Math.floor(budget / nominalCount), budget, itemCount: nominalCount }; - } - if (continuousCount > 0) { - const { budget } = computeElasticBudget(continuousCount, baseDimension, params); - return { step: Math.floor(budget / continuousCount), budget, itemCount: continuousCount }; + const itemCount = nominalCount > 0 ? nominalCount : continuousCount; + if (itemCount > 0) { + const fit = Math.max(0, Math.min(1, params.bandStepFit ?? 0)); + const baseSpanStep = baseDimension / itemCount; + const capacityStep = baseDimension * (params.bandStepFitCapacity ?? 1) / itemCount; + // Fit only addresses unused room on a sparse categorical axis. When + // the base pitch is already under pressure, preserve it as the target + // and let the existing elastic budget stretch/compress the canvas. + const preferredStep = baseSpanStep > params.defaultStepSize + ? params.defaultStepSize * (1 - fit) + capacityStep * fit + : params.defaultStepSize; + + // A preferred pitch that already fits should not be expanded to consume + // unused room. Under pressure, the existing elastic budget decides how + // much the canvas may grow before the pitch is compressed. + if (preferredStep <= baseSpanStep) { + return { step: Math.floor(preferredStep), budget: baseDimension, itemCount }; + } + const { budget } = computeElasticBudget(itemCount, baseDimension, { + ...params, + defaultStepSize: preferredStep, + }); + return { + step: Math.floor(Math.min(preferredStep, budget / itemCount)), + budget, + itemCount, + }; } return { step: params.defaultStepSize, budget: baseDimension, itemCount: 0 }; } diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index 46c35f27..555a4299 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -202,8 +202,11 @@ export { type DesignDecisions, type ThemeReport, type Presence, + type GeometryKind, + type ThemeGeometry, type GroundingContext, groundTheme, + resolveGeometry, THEME_PRESETS, DEFAULT_THEME_ICON, listThemePresets, diff --git a/packages/flint-js/src/core/resolve-semantics.ts b/packages/flint-js/src/core/resolve-semantics.ts index 2490a9d8..4b9653ee 100644 --- a/packages/flint-js/src/core/resolve-semantics.ts +++ b/packages/flint-js/src/core/resolve-semantics.ts @@ -65,6 +65,23 @@ function timestampToMs(val: number): number { return val <= MAX_TIMESTAMP_SEC ? val * 1000 : val; } +function inferImplicitSemanticType(fieldName: string, values: any[]): string { + const tokens = fieldName + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + if (!tokens.includes('year')) return ''; + + const observed = values.filter(value => value != null && value !== ''); + if (new Set(observed.map(String)).size <= 1) return ''; + const allYears = observed.every(value => { + const numeric = typeof value === 'number' ? value : Number(value); + return Number.isInteger(numeric) && numeric >= 1500 && numeric <= 2200; + }); + return allYears ? 'Year' : ''; +} + function looksLikeDateString(s: string): boolean { const t = s.trim(); return /^\d|^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i.test(t); @@ -246,9 +263,14 @@ export function convertTemporalData( if (data.length === 0) return data; const keys = Object.keys(data[0]); + const fieldValues = Object.fromEntries(keys.map(key => [key, data.map(row => row[key])])); + const effectiveSemanticTypes = Object.fromEntries(keys.map(key => [ + key, + toTypeString(semanticTypes[key]) || inferImplicitSemanticType(key, fieldValues[key]), + ])); const temporalKeys = keys.filter((k: string) => { - const st = toTypeString(semanticTypes[k]); - const vc = inferVisCategory(data.map(r => r[k])); + const st = effectiveSemanticTypes[k]; + const vc = inferVisCategory(fieldValues[k]); const stCategory = st ? getVisCategory(st) : null; return vc === 'temporal' || stCategory === 'temporal' || st === 'Decade'; }); @@ -259,7 +281,7 @@ export function convertTemporalData( return values.map((r: any) => { for (const temporalKey of temporalKeys) { const val = r[temporalKey]; - const st = toTypeString(semanticTypes[temporalKey]); + const st = effectiveSemanticTypes[temporalKey]; if (typeof val === 'number') { if (st === 'Year' || st === 'Decade') { @@ -336,10 +358,12 @@ export function resolveChannelSemantics( if (!fieldName) continue; const rawAnnotation = semanticTypes[fieldName]; - const semanticType = typeof rawAnnotation === 'string' + const suppliedSemanticType = typeof rawAnnotation === 'string' ? (rawAnnotation || '') : (rawAnnotation?.semanticType ?? ''); const fieldValues = data.map(r => r[fieldName]); + const semanticType = suppliedSemanticType + || inferImplicitSemanticType(fieldName, fieldValues); // Resolve encoding type const typeDecision = resolveEncodingTypeDecision( @@ -367,7 +391,7 @@ export function resolveChannelSemantics( // Build ChannelSemantics entry // Stage 1: resolve field-level semantics (data identity) - const fc = resolveFieldSemantics(rawAnnotation, fieldName, fieldValues); + const fc = resolveFieldSemantics(rawAnnotation || semanticType, fieldName, fieldValues); const annotation = fc.semanticAnnotation; // Stage 2: layer on channel-specific visualization decisions diff --git a/packages/flint-js/src/core/theme/ground.ts b/packages/flint-js/src/core/theme/ground.ts index fa52f419..6cdbd7ae 100644 --- a/packages/flint-js/src/core/theme/ground.ts +++ b/packages/flint-js/src/core/theme/ground.ts @@ -14,6 +14,7 @@ import type { DesignDecisions, + GeometryKind, LegendPlacement, NumericGuard, Presence, @@ -23,7 +24,9 @@ import type { ResolvedSeriesInk, ResolvedText, SizeToken, + ThemeGeometry, ThemeGuard, + ThemeMarks, ThemeReport, ThemeSpec, TypeRole, @@ -107,6 +110,12 @@ export interface GroundingContext { * written there, the delegation has nowhere to go. */ titled?: boolean; + /** + * The headline and deck as written. A house that omits axis titles is + * delegating the naming to these words, so grounding has to be able to read + * them and check the delegation was actually honoured. + */ + headline?: string; /** The surface the host page provides, if the theme defers to it. */ hostSurface?: string; /** @@ -121,6 +130,12 @@ export interface GroundingContext { * exactly as a house's own `always` does. */ valueLabels?: 'on' | 'off'; + + /** + * The geometries the chart template builds. Geometry the template cannot + * build is dropped rather than carried to a renderer that would ignore it. + */ + geometryKinds?: GeometryKind[]; } // --------------------------------------------------------------------------- @@ -238,6 +253,194 @@ export function resolveChartDefaults( return report; } +// --------------------------------------------------------------------------- +// Geometry +// --------------------------------------------------------------------------- + +/** + * The geometry a house states through `marks`, read as the base profile. + * + * `marks` is the older, flatter way of saying most of this, and it is still the + * only thing nine shipped presets say. Deriving the profile from it is what + * makes the two vocabularies one: a theme that never writes `geometry` gets + * exactly the marks it always had, and a theme that does is overriding a + * profile rather than opening a second channel to the same renderer. + */ +function geometryFromMarks(marks: ThemeMarks | undefined): ThemeGeometry { + if (!marks) return {}; + const interpolation = marks.interpolation; + return { + line: { + width: marks.strokeWeight, + cap: marks.strokeCap, + join: marks.strokeJoin, + interpolation, + }, + point: { + presence: marks.point?.presence, + size: marks.point?.size, + fill: marks.point?.fill, + outlineWidth: marks.outline?.weight, + }, + area: { + opacity: marks.fillOpacity, + interpolation, + }, + band: { + fraction: marks.bandFraction, + cornerRadius: marks.cornerRadius, + opacity: marks.fillOpacity, + outline: marks.outline?.presence, + outlineWidth: marks.outline?.weight, + }, + arc: { + cornerRadius: marks.cornerRadius, + gap: marks.slice?.gap, + gapStyle: marks.slice?.gapStyle, + }, + cell: { + gap: marks.tile?.gap, + }, + }; +} + +/** Overlay `patch` onto `base`, one geometry kind at a time; `undefined` never overwrites. */ +function mergeGeometry(base: ThemeGeometry, patch: ThemeGeometry | undefined): ThemeGeometry { + if (!patch) return base; + const out: Record = { ...base }; + for (const [kind, values] of Object.entries(patch)) { + if (!values) continue; + const merged: Record = { ...(out[kind] ?? {}) }; + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) merged[key] = value; + } + out[kind] = merged; + } + return out as ThemeGeometry; +} + +function stripUndefined(geometry: ThemeGeometry): ThemeGeometry { + const out: Record = {}; + for (const [kind, values] of Object.entries(geometry)) { + if (!values) continue; + const kept: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) kept[key] = value; + } + if (Object.keys(kept).length) out[kind] = kept; + } + return out as ThemeGeometry; +} + +/** + * Settle the geometry this chart is built from: the house's `marks`, then its + * `geometry` profile. + * + * `declared` is the template's own list of shapes it builds. A house that hands + * arc geometry to a line chart is saying something the chart cannot hear, so it + * is dropped and reported rather than silently carried into a renderer that + * would ignore it anyway. + * + * Conditional geometry — a thinner line for a four-series chart, no dots in a + * sixteen-panel facet — arrives here already folded in, because variants are + * merged into `theme` before this runs. + */ +export function resolveGeometry( + theme: ThemeSpec | undefined, + chartType: string, + declared: GeometryKind[] | undefined, +): { geometry: ThemeGeometry; report: ThemeReport[] } { + const report: ThemeReport[] = []; + if (!theme) return { geometry: {}, report }; + + let geometry = geometryFromMarks(theme.marks); + geometry = mergeGeometry(geometry, theme.geometry); + geometry = stripUndefined(geometry); + + // Only filter when the template says what it builds; a template that has + // not declared its shapes yet keeps the whole profile rather than losing it. + if (declared) { + const kinds = new Set(declared); + for (const kind of Object.keys(geometry)) { + if (kinds.has(kind)) continue; + delete (geometry as Record)[kind]; + if (theme.geometry?.[kind as GeometryKind]) { + report.push({ + stage: 'ground', + path: `geometry.${kind}`, + message: `\`${chartType}\` builds no ${kind} — the house's ${kind} geometry does not apply here`, + }); + } + } + } + + return { geometry, report }; +} + +/** + * Fold resolved geometry back into the marks vocabulary grounding already + * speaks, so the per-chart profile reaches the renderer through the one path + * that was already there instead of a parallel one. + */ +export function geometryToMarks( + geometry: ThemeGeometry | undefined, + base: ThemeMarks | undefined, +): ThemeMarks | undefined { + if (!geometry || Object.keys(geometry).length === 0) return base; + const marks: ThemeMarks = { ...(base ?? {}) }; + const { line, point, area, band, arc, cell } = geometry; + + if (line?.width !== undefined) marks.strokeWeight = line.width; + if (line?.cap !== undefined) marks.strokeCap = line.cap; + if (line?.join !== undefined) marks.strokeJoin = line.join; + if (line?.interpolation !== undefined) marks.interpolation = line.interpolation; + else if (area?.interpolation !== undefined) marks.interpolation = area.interpolation; + + // One opacity reaches every filled mark, so a band that states its own wins + // over an area's: a chart that draws both is a bar chart with a wash, and + // the bars are what the reader measures. + if (area?.opacity !== undefined) marks.fillOpacity = area.opacity; + if (band?.opacity !== undefined) marks.fillOpacity = band.opacity; + if (cell?.opacity !== undefined) marks.fillOpacity = cell.opacity; + + if (band?.fraction !== undefined) marks.bandFraction = band.fraction; + if (band?.cornerRadius !== undefined) marks.cornerRadius = band.cornerRadius; + if (arc?.cornerRadius !== undefined) marks.cornerRadius = arc.cornerRadius; + + if (point) { + const resolved = { ...(marks.point ?? {}) }; + if (point.presence !== undefined) resolved.presence = point.presence; + if (point.size !== undefined) resolved.size = point.size; + if (point.vertexSize !== undefined) resolved.secondarySize = point.vertexSize; + if (point.fill !== undefined) resolved.fill = point.fill; + if (Object.keys(resolved).length) marks.point = resolved; + } + + const outlinePresence = band?.outline ?? area?.edge; + const outlineWidth = band?.outlineWidth ?? area?.edgeWidth ?? point?.outlineWidth; + if (outlinePresence !== undefined || outlineWidth !== undefined) { + marks.outline = { + ...(marks.outline ?? {}), + ...(outlinePresence !== undefined ? { presence: outlinePresence } : {}), + ...(outlineWidth !== undefined ? { weight: outlineWidth } : {}), + }; + } + + if (arc?.gap !== undefined || arc?.gapStyle !== undefined) { + marks.slice = { + ...(marks.slice ?? {}), + ...(arc.gap !== undefined ? { gap: arc.gap } : {}), + ...(arc.gapStyle !== undefined ? { gapStyle: arc.gapStyle } : {}), + }; + } + + if (cell?.gap !== undefined) { + marks.tile = { ...(marks.tile ?? {}), gap: cell.gap }; + } + + return marks; +} + const TEXT_TOKENS: Record = { '100': 10, '200': 12, '300': 14, '400': 16, '500': 20, '600': 24, hero700: 28, hero800: 32, hero900: 40, hero1000: 68, @@ -600,7 +803,8 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe }; const gridStyle = structure.grid?.style ?? 'solid'; - const gridDash = gridStyle === 'dashed' ? [3, 3] : gridStyle === 'dotted' ? [1, 3] : undefined; + const gridDash = structure.grid?.dash + ?? (gridStyle === 'dashed' ? [3, 3] : gridStyle === 'dotted' ? [1, 3] : undefined); const gridWeight = structure.grid?.weight ?? 1; const measureGrid: ResolvedRule = { @@ -681,37 +885,26 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe // binned range is in the second group even though it sits on a // categorical axis — its labels are numbers wearing an order. // - // A house that drops its axis titles altogether is leaning on the - // headline to name the measure, and a headline names one. Where both - // rulers carry a measure — one quantity plotted against another — the - // headline cannot say which is which, and two rows of bare numbers - // name nothing. The titles stay. - // - // And a chart with no headline at all has nothing to lean on. `omit` - // is a delegation, not a deletion: where the words it delegates to were - // never written, the labels that name nothing get their title back. - const twoMeasures = bindings.measureChannels.includes('x') - && bindings.measureChannels.includes('y'); + // A headline is narrative, not an axis binding. Even when it repeats a + // field word, it does not reliably state the quantity, unit, direction + // or which ruler carries it. `The female–male life gap` does not tell a + // reader that 55–85 means life expectancy in years; `Olympic rank` does + // not state the rank direction. Numeric, rank and bin axes therefore + // keep their title under every policy. `omit` may still remove titles + // over labels that name their own kind, such as months or countries. const selfNaming = labelsNameThemselves(ctx, channel); - const undelegated = !selfNaming && ctx.titled !== true; const showTitle = axisTitlesPolicy === 'always' ? true - : axisTitlesPolicy === 'omit' - ? ((twoMeasures && role === 'measure') || undelegated) - : !selfNaming; + : !selfNaming; if (axisTitlesPolicy === 'whenAmbiguous' && selfNaming !== (role !== 'measure')) { say(`structure.axis.${role}.title`, selfNaming ? `the labels on ${channel} name their own kind — a title over them would repeat what is already read` : `the labels on ${channel} are values, not names — without a title nothing on the axis says what they count`); } - if (axisTitlesPolicy === 'omit' && undelegated) { - say('annotation.axisTitles', - `the house omits axis titles because the headline names the measure — this chart has no headline, so the title on ${channel} stays`); - } - if (axisTitlesPolicy === 'omit' && twoMeasures && role === 'measure' && channel === 'x') { + if (axisTitlesPolicy === 'omit' && !selfNaming) { say('annotation.axisTitles', - 'both rulers carry a measure — a headline can name one of them, so the axis titles are kept'); + `the labels on ${channel} are values, not names — a headline cannot bind them to a quantity, so the axis title stays`); } // A measure axis is a ruler, and how finely it is graduated is a house @@ -771,6 +964,9 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe show: showTitle, ...axisTitleText, ...(showTitle ? { placement: theme.annotation?.axisTitlePlacement } : {}), + ...(showTitle && theme.annotation?.axisTitleGap != null + ? { gap: Math.max(0, theme.annotation.axisTitleGap) } + : {}), ...(titleUnit ? { unit: titleUnit } : {}), }, tickCount, @@ -892,7 +1088,13 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe // placement) would stack that axis's tick labels under the names — // "Uni1ted", "Ch2na", "Ja3an" on a bump chart's rank axis. The end labels // own the right side; the measure axis falls back to the left. - if (placement === 'seriesEnd' || placement === 'inline') { + // + // Only when those names are actually drawn. A placement is chosen off the + // ranked list whether or not there is a key to place, so a single-series + // line chart — no series to name, nothing in the right margin — was + // evicting its own ruler to the left and losing the house's right-hand + // axis on the plainest chart it draws. + if (legendShow && (placement === 'seriesEnd' || placement === 'inline')) { for (const ch of bindings.measureChannels) { const ax = axes[ch]; if (ax && ax.orient === 'right') { @@ -1199,19 +1401,43 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe } // The same argument, one axis over: once every mark states its own value, - // the measure axis is a second copy of the same information. + // the *ruler* is a second copy of the same information. Its title is not. + // A printed `42` says how much and never says of what — the title is the + // only thing naming the quantity, and taking it away with the gradations + // leaves a grid of anonymous numbers. What the title says is still the + // title policy's call; this only stops the ruler dragging it out. if (dlShow && structure.axis?.measure?.suppressWhenValuesPrinted) { + let keptTitle = false; for (const ch of bindings.measureChannels) { const ax = axes[ch]; if (!ax) continue; ax.label.show = false; ax.grid = { show: false, color: 'transparent', width: 0 }; - ax.title = { ...ax.title, show: false }; ax.ticks = { ...ax.ticks, show: false, size: 0 }; ax.domain = { ...ax.domain, show: false }; + if (ax.title.show) keptTitle = true; } say('structure.axis.measure.suppressWhenValuesPrinted', - 'measure axis removed — every mark prints its own value'); + keptTitle + ? 'measure ruler removed — every mark prints its own value; the axis title stays, because a printed number says how much and not of what' + : 'measure axis removed — every mark prints its own value'); + } + + // The mirror of the rule above, and the floor under it. A house strips its + // grid, its rule and its ticks because the number is printed on the mark — + // that is the consulting-deck bar, and there the scaffolding really is + // redundant. A scatter prints nothing, so the same house hands back + // readings floating on blank paper with no way to judge one against + // another. The house may choose how a value is read; it may not leave no + // way to read one, so the quiet grid returns. + if (!dlShow) { + for (const ch of bindings.measureChannels) { + const ax = axes[ch]; + if (!ax || ax.grid.show || ax.domain.show || ax.ticks.show) continue; + ax.grid = { ...rule('quiet', structureInk.grid, 'quiet', gridWeight), dash: gridDash }; + say(`axes.${ch}.grid`, + 'no mark prints its value and the ruler draws nothing — the quiet grid returns, so a reading can be judged against something'); + } } const measureChannel = bindings.measureChannels[0]; @@ -1254,7 +1480,13 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe } // --- marks -------------------------------------------------------------- - const marksSpec = theme.marks ?? {}; + // Geometry is resolved here, after variants, so a house's conditional + // geometry — a thinner line where four series share the plot — is already + // part of `theme` by the time the profile is read. + const marksSpec = geometryToMarks( + resolveGeometry(theme, ctx.chartType, ctx.geometryKinds).geometry, + theme.marks, + ) ?? {}; const separatorInk = marksSpec.separator?.source === 'surface' ? plot : structureInk.rule ?? structureInk.grid; @@ -1327,6 +1559,11 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe ? { show: (marksSpec.point?.presence ?? 'omit') !== 'omit', size: marksSpec.point?.size, + secondarySize: marksSpec.point?.secondarySize + ?? Math.min(marksSpec.point?.size ?? 25, 25), + // Only where the house sized vertices itself; otherwise a line's + // dots keep taking the primary size, as they always have. + vertexSize: marksSpec.point?.secondarySize, // Only a house that spoke about its dots' fill decides how // they are filled; inventing an answer here would re-fill // every scatter it has for the sake of a line chart's @@ -1532,7 +1769,13 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe : undefined, marks, facets, - layout: { padding, density }, + layout: { + padding, + density, + plotWidth: ctx.layout.subplotWidth, + xStep: ctx.layout.xStep, + canvasWidth: ctx.canvasSize?.width, + }, statistics: theme.annotation?.statistics?.show?.length ? { show: theme.annotation.statistics.show, @@ -1664,7 +1907,12 @@ function groundSeriesInk( // Continuous series: a ramp, not an indexed set. if (seriesType === 'quantitative') { - const diverging = signals.isSigned && Boolean(s.diverging); + // Signed values normally use a diverging ramp, but a house may + // deliberately read the field as ordered magnitude instead. McKinsey + // does this on heatmaps: one navy sequence plus printed values, rather + // than introducing a warm ink the house does not otherwise own. + const signedSequential = signals.isSigned && selection.signed === 'sequential'; + const diverging = signals.isSigned && !signedSequential && Boolean(s.diverging); const ramp: Ramp | undefined = offSurface(diverging ? s.diverging : s.sequential, surfaceColour, say); if (ramp?.stops?.length) { const consumption = ramp.consumption ?? 'interpolate'; diff --git a/packages/flint-js/src/core/theme/index.ts b/packages/flint-js/src/core/theme/index.ts index 7b9b0b07..12ae91a4 100644 --- a/packages/flint-js/src/core/theme/index.ts +++ b/packages/flint-js/src/core/theme/index.ts @@ -4,5 +4,6 @@ export * from './types.js'; export * from './presence.js'; export { groundTheme } from './ground.js'; +export { resolveGeometry, geometryToMarks } from './ground.js'; export type { GroundingContext } from './ground.js'; export { THEME_PRESETS, DEFAULT_THEME_ICON, listThemePresets, resolveThemeSpec } from './presets.js'; diff --git a/packages/flint-js/src/core/theme/presets/cartoon.ts b/packages/flint-js/src/core/theme/presets/cartoon.ts index c558582a..9a6a7da2 100644 --- a/packages/flint-js/src/core/theme/presets/cartoon.ts +++ b/packages/flint-js/src/core/theme/presets/cartoon.ts @@ -213,6 +213,7 @@ export const cartoon: ThemePreset = { layout: { density: 'normal', targetWidth: 300, + bandStepFit: 0.75, titleBlock: { anchor: 'start', gap: 'normal', diff --git a/packages/flint-js/src/core/theme/presets/datawrapper.ts b/packages/flint-js/src/core/theme/presets/datawrapper.ts index b8697c58..8c186416 100644 --- a/packages/flint-js/src/core/theme/presets/datawrapper.ts +++ b/packages/flint-js/src/core/theme/presets/datawrapper.ts @@ -140,6 +140,14 @@ export const datawrapper: ThemePreset = { "weight": 1 } }, + "geometry": { + "arc": { + "gap": 1.5 + }, + "cell": { + "gap": 1.5 + } + }, "labels": { "truncation": "never" }, @@ -176,6 +184,7 @@ export const datawrapper: ThemePreset = { "layout": { "density": "normal", "targetWidth": 300, + "bandStepFit": 1, "titleBlock": { "anchor": "start" } diff --git a/packages/flint-js/src/core/theme/presets/economist.ts b/packages/flint-js/src/core/theme/presets/economist.ts index f1634bcd..d4ca9b30 100644 --- a/packages/flint-js/src/core/theme/presets/economist.ts +++ b/packages/flint-js/src/core/theme/presets/economist.ts @@ -100,7 +100,12 @@ export const economist: ThemePreset = { "color": "#54585a" }, "axisLabel": { - "size": "text.100" + "size": 11 + }, + "axisTitle": { + "size": 11, + "weight": "regular", + "color": "#54585a" } }, "structure": { @@ -143,6 +148,11 @@ export const economist: ThemePreset = { 450 ] }, + "geometry": { + "cell": { + "gap": 0.6 + } + }, "labels": { "truncation": "never", "angle": "auto" @@ -163,6 +173,8 @@ export const economist: ThemePreset = { }, "annotation": { "axisTitles": "omit", + "axisTitlePlacement": "flatAboveAxis", + "axisTitleGap": 8, "unit": "everyTick" }, "furniture": [ @@ -181,6 +193,7 @@ export const economist: ThemePreset = { ], "layout": { "density": "compact", + "bandStepFit": 0.55, "titleBlock": { "anchor": "start", "gap": "tight" diff --git a/packages/flint-js/src/core/theme/presets/mckinsey.ts b/packages/flint-js/src/core/theme/presets/mckinsey.ts index b6df40e7..3c602669 100644 --- a/packages/flint-js/src/core/theme/presets/mckinsey.ts +++ b/packages/flint-js/src/core/theme/presets/mckinsey.ts @@ -111,7 +111,7 @@ export const mckinsey: ThemePreset = { }, "selection": { "partToWhole": "categorical", - "signed": "diverging" + "signed": "sequential" }, "overflow": "#b6bfc7" }, @@ -124,9 +124,15 @@ export const mckinsey: ThemePreset = { "size": "text.300", "weight": "bold" }, - "axisLabel": { + "deck": { "size": "text.200" }, + "axisLabel": { + "size": "text.100" + }, + "axisTitle": { + "size": "text.100" + }, "valueLabel": { "size": "text.200", "weight": "semibold", @@ -153,7 +159,7 @@ export const mckinsey: ThemePreset = { "baseline": "full" }, "marks": { - "bandFraction": 0.6, + "bandFraction": 0.7, "strokeWeight": 2, "connector": { "presence": "full", @@ -172,6 +178,11 @@ export const mckinsey: ThemePreset = { "gap": 1 } }, + "geometry": { + "cell": { + "gap": 0.8 + } + }, "labels": { "truncation": "never", "angle": "horizontal" @@ -206,7 +217,13 @@ export const mckinsey: ThemePreset = { "gap": "loose", "deckGap": "loose" }, - "bandStep": 80 + // McKinsey exhibits use substantial bars inside an airy page: the + // whitespace lives around the chart and its annotations, not in + // unusually narrow marks or compressed category rows. + "bandStep": 24, + // Sparse exhibits broaden their category rhythm, but retain more + // of the house's stable base step than the renderer's full-span fit. + "bandStepFit": 0.35 }, "compileDefaults": { "baseSize": { "width": 440, "height": 300 } diff --git a/packages/flint-js/src/core/theme/presets/nature.ts b/packages/flint-js/src/core/theme/presets/nature.ts index 47827238..fc120897 100644 --- a/packages/flint-js/src/core/theme/presets/nature.ts +++ b/packages/flint-js/src/core/theme/presets/nature.ts @@ -176,6 +176,11 @@ export const nature: ThemePreset = { "shape" ] }, + "geometry": { + "point": { + "vertexSize": 26 + } + }, "labels": { "truncation": "never" }, @@ -208,6 +213,7 @@ export const nature: ThemePreset = { "layout": { "density": "compact", "targetWidth": 252, + "bandStepFit": 0.2, "titleBlock": { "anchor": "middle", "position": "bottom", diff --git a/packages/flint-js/src/core/theme/presets/nyt.ts b/packages/flint-js/src/core/theme/presets/nyt.ts index a759871f..445643fc 100644 --- a/packages/flint-js/src/core/theme/presets/nyt.ts +++ b/packages/flint-js/src/core/theme/presets/nyt.ts @@ -196,6 +196,7 @@ export const nyt: ThemePreset = { }, "layout": { "density": "normal", + "bandStepFit": 0.7, "titleBlock": { "anchor": "start", "deckGap": "tight" diff --git a/packages/flint-js/src/core/theme/presets/pop.ts b/packages/flint-js/src/core/theme/presets/pop.ts index e29dcc29..57fe8b38 100644 --- a/packages/flint-js/src/core/theme/presets/pop.ts +++ b/packages/flint-js/src/core/theme/presets/pop.ts @@ -80,6 +80,6 @@ export const pop: ThemePreset = { separator: { presence: 'emphasised', width: 3, source: 'structure' }, }, dataLabels: { show: 'whenTheyFit', placement: 'atMark', inkMode: 'contrastWithMark' }, - layout: { density: 'normal', titleBlock: { anchor: 'start', gap: 'tight' } }, + layout: { density: 'normal', bandStepFit: 0.85, titleBlock: { anchor: 'start', gap: 'tight' } }, }, }; \ No newline at end of file diff --git a/packages/flint-js/src/core/theme/presets/powerbi-light.ts b/packages/flint-js/src/core/theme/presets/powerbi-light.ts index 16de90e3..e49229db 100644 --- a/packages/flint-js/src/core/theme/presets/powerbi-light.ts +++ b/packages/flint-js/src/core/theme/presets/powerbi-light.ts @@ -223,6 +223,7 @@ export const powerbiLight: ThemePreset = { }, "layout": { "density": "compact", + "bandStepFit": 1, "titleBlock": { "anchor": "start", "gap": "tight", diff --git a/packages/flint-js/src/core/theme/presets/powerbi.ts b/packages/flint-js/src/core/theme/presets/powerbi.ts index ecb52865..f1e6d43a 100644 --- a/packages/flint-js/src/core/theme/presets/powerbi.ts +++ b/packages/flint-js/src/core/theme/presets/powerbi.ts @@ -187,6 +187,33 @@ export const powerbi: ThemePreset = { "weight": 2 } }, + "geometry": { + "band": { + "cornerRadius": 3 + }, + "cell": { + "gap": 1 + }, + "point": { + "presence": "full", + "vertexSize": 28 + } + }, + "variants": [ + { + "when": { + "isFaceted": true + }, + "then": { + "geometry": { + "point": { + "presence": "omit" + } + } + }, + "because": "a panel in a grid has no room for a dot at every reading — its own 16-panel exhibit drops them" + } + ], "labels": { "truncation": "never" }, @@ -226,6 +253,7 @@ export const powerbi: ThemePreset = { }, "layout": { "density": "compact", + "bandStepFit": 1, "titleBlock": { "anchor": "start", "gap": "tight", diff --git a/packages/flint-js/src/core/theme/presets/swiss.ts b/packages/flint-js/src/core/theme/presets/swiss.ts index 01f12182..15606fe8 100644 --- a/packages/flint-js/src/core/theme/presets/swiss.ts +++ b/packages/flint-js/src/core/theme/presets/swiss.ts @@ -194,6 +194,7 @@ export const swiss: ThemePreset = { layout: { density: 'normal', targetWidth: 300, + bandStepFit: 0.5, titleBlock: { anchor: 'start', gap: 'normal', diff --git a/packages/flint-js/src/core/theme/types.ts b/packages/flint-js/src/core/theme/types.ts index 895d453c..dafa0f78 100644 --- a/packages/flint-js/src/core/theme/types.ts +++ b/packages/flint-js/src/core/theme/types.ts @@ -58,6 +58,11 @@ export interface AxisRole { /** `opposite` = the far side of the plot (top for x, right for y). */ placement?: 'default' | 'opposite'; tickLabels?: 'all' | 'observed' | 'endpoints' | 'sparse'; + /** + * How finely a *measure* ruler is graduated: one gradation per 60 / 45 / 30px + * of axis, floored at three. A hint — the renderer still rounds to its own + * 1/2/5/10 step, so a dense ask on a short axis can land on half units. + */ tickDensity?: 'sparse' | 'normal' | 'dense'; /** * Drop the axis entirely when every mark already prints its own value. @@ -183,6 +188,12 @@ export interface ThemeStructure { measure?: Presence; category?: Presence; style?: 'solid' | 'dashed' | 'dotted'; + /** + * The dash rhythm in px, `[dash, gap]`. `style` only names a family and + * picks a default; the length of the dash is its own decision, and a + * long dash reads as a guide where a short one reads as a dotted rule. + */ + dash?: number[]; /** Stroke width of visible gridlines in px. */ weight?: number; /** @@ -268,17 +279,22 @@ export interface ThemeMarks { */ presence?: Presence; /** - * How big a dot this house draws, as an area in px² — the way the - * renderer states a point's size and the way the size channel is read. - * One number, wherever a dot appears: at a line's vertex, on a scatter, - * at the ends of a dumbbell. A house that wanted its scatter dots and - * its vertex dots at different sizes would be saying that the same ink - * means two things, and the eye does not read them that way. + * How big a primary dot this house draws, as an area in px² — the way + * the renderer states a point's size and the way the size channel is + * read. This covers marks whose point is itself the reading: a scatter, + * a dot plot, or the ends of a dumbbell. * * Where the house says nothing the renderer's default stands. The * layout remains free to shrink it when the plot runs short of room. */ size?: number; + /** + * How big a supporting vertex is, as an area in px². A radar vertex or + * a sampled point on a connected shape confirms the path's position; + * it is not the primary mark and must not obscure that path or its + * grid. Defaults to the smaller of `size` and 25px². + */ + secondarySize?: number; fill?: 'solid' | 'hollow'; halo?: { presence?: Presence; width?: number }; }; @@ -363,7 +379,17 @@ export interface ThemeAnnotation { * one is written. Ranks and binned ranges count as numbers. */ axisTitles?: 'omit' | 'whenAmbiguous' | 'always'; + /** + * Where a still-needed axis title sits. `flatAboveAxis` lays it straight + * at the head of its own ruler, beside the values it names. + */ axisTitlePlacement?: 'rotated' | 'flatAboveAxis' | 'inline'; + /** + * Distance in px from an axis title to the plot-side edge it labels. + * Realization maps this to native title padding or, for a flat y title, + * to its offset above the plot. + */ + axisTitleGap?: number; unitsInAxisTitle?: boolean; numberFormat?: { precision?: 'auto' | 'integer' | 'one' | 'two'; @@ -394,6 +420,7 @@ export interface ThemeFacets { } export interface ThemeLayout { + /** Air around the plot. Not the ruler's gradations — those are `tickDensity`. */ density?: 'compact' | 'normal' | 'airy'; targetWidth?: number; titleBlock?: { @@ -411,6 +438,13 @@ export interface ThemeLayout { deckGap?: 'tight' | 'normal' | 'loose'; }; bandStep?: number; + /** + * How strongly category pitch follows the available discrete-axis span. + * On sparse one-banded axes, `0` preserves `bandStep`, `1` fills the span, + * and intermediate values linearly blend the two. Under pressure normal + * elasticity applies to `bandStep`; two-banded cell grids ignore this. + */ + bandStepFit?: number; } /** A predicate over signals the compiler already resolves. Deliberately closed. */ @@ -439,6 +473,101 @@ export interface ThemeVariant { because?: string; } +/** + * The geometries a chart can be built out of, named by what the reader does + * with the shape rather than by the renderer's mark. + * + * `band` and `cell` are the reason this list exists and is not just a list of + * mark types: Vega-Lite draws both with `rect`, but a bar in a row and a tile + * in a grid take opposite answers to the same question. A gap between bars is + * rhythm; a gap between cells cuts a continuous field into a table. + */ +export type GeometryKind = 'line' | 'point' | 'area' | 'band' | 'arc' | 'cell'; + +export interface LineGeometry { + width?: number; + opacity?: number; + cap?: 'butt' | 'round' | 'square'; + join?: 'miter' | 'round' | 'bevel'; + interpolation?: 'linear' | 'monotone' | 'step'; +} + +export interface PointGeometry { + /** Whether a line carries a dot at each reading. A scatter's dots are the chart, not this. */ + presence?: Presence; + /** Area in px², the way both the renderer and the size channel state a dot. */ + size?: number; + /** + * The area a dot takes when it is a *vertex* on a path rather than the + * reading itself. McKinsey draws a 30px² dot on its line and a 90px² one on + * a dumbbell, where the dot is the measurement; one number cannot say both. + * Falls back to {@link size}. + */ + vertexSize?: number; + fill?: 'solid' | 'hollow'; + outlineWidth?: number; +} + +export interface AreaGeometry { + opacity?: number; + /** The line along the top of the fill: an area that keeps its edge reads as a line with a wash. */ + edge?: Presence; + edgeWidth?: number; + interpolation?: 'linear' | 'monotone' | 'step'; +} + +export interface BandGeometry { + /** How much of its step the bar fills, 0–1. The rest is the gap. */ + fraction?: number; + /** Rounding at the *value* end only, so a stack still reads as one column. */ + cornerRadius?: number; + opacity?: number; + outline?: Presence; + outlineWidth?: number; +} + +export interface ArcGeometry { + cornerRadius?: number; + /** How far neighbouring wedges stand apart, in px. */ + gap?: number; + /** `rule` paints the shared edge; `pad` swings the wedges apart. */ + gapStyle?: 'rule' | 'pad'; +} + +export interface CellGeometry { + /** Cut between tiles, in px. Zero keeps the grid a continuous field. */ + gap?: number; + cornerRadius?: number; + opacity?: number; +} + +/** + * Geometry the house states once and every chart built from that shape reads. + * + * Measured against the theme lab: `line.width`, `point.presence` and + * `point.size` all split inside a replicated cluster — one chart, many + * languages, different answers — so they are house decisions and belong here. + * `point.fill` did not split, which is why nothing here asks a house to state it. + * + * There is deliberately no per-chart-type sibling to this block. The lab was + * asked for one and refused: of the twelve (house, chart type) groups holding + * more than one chart, eight disagree with themselves, so a rule keyed by chart + * type could not have carried them anyway. What the disagreements track is + * series count and faceting — NYT, the Economist and Power BI all thin their + * line to exactly 2px for the four-series chart and run 2.2–2.5px for a lone + * one, and Power BI drops its dots only on the sixteen-panel facet. Those are + * `variants`, over signals the compiler already resolves, and a variant may + * carry a `geometry` block. + */ +export interface ThemeGeometry { + line?: LineGeometry; + point?: PointGeometry; + area?: AreaGeometry; + band?: BandGeometry; + arc?: ArcGeometry; + cell?: CellGeometry; +} + /** * The one block that is allowed to name a chart type. * @@ -502,6 +631,7 @@ export interface ThemeSpec { furniture?: ThemeFurniture[]; facets?: ThemeFacets; layout?: ThemeLayout; + geometry?: ThemeGeometry; chartDefaults?: ThemeChartDefaults; compileDefaults?: ThemeCompileDefaults; interaction?: { tooltipFormat?: string }; @@ -586,7 +716,7 @@ export interface ResolvedAxis { ticks: ResolvedRule & { size: number; offset: number }; grid: ResolvedRule; label: ResolvedText & { show?: boolean; limit?: number; padding: number; flush?: boolean; angle?: number }; - title: { show: boolean; placement?: 'rotated' | 'flatAboveAxis' | 'inline'; unit?: string } & ResolvedText; + title: { show: boolean; placement?: 'rotated' | 'flatAboveAxis' | 'inline'; gap?: number; unit?: string } & ResolvedText; /** Preferred tick count; undefined = let the renderer choose. */ tickCount?: number; /** @@ -714,7 +844,16 @@ export interface ResolvedMarks { cornerRadius?: number; /** A stroke around each filled bar/wedge/point: the sticker edge (thin bars skip it). */ outline?: { color: string; width: number }; - point?: { show: boolean; size?: number; filled?: boolean; haloColor?: string; haloWidth?: number }; + point?: { + show: boolean; + size?: number; + secondarySize: number; + /** Set only where the house sized path vertices apart from its primary dots. */ + vertexSize?: number; + filled?: boolean; + haloColor?: string; + haloWidth?: number; + }; /** The area a sized mark may take, smallest to largest, in px². */ sizeRange?: [number, number]; /** The area below which a sized mark stops being a mark. */ @@ -789,7 +928,15 @@ export interface DesignDecisions { spacing?: number; preferredColumns?: number; }; - layout: { padding: number; density: 'compact' | 'normal' | 'airy' }; + /** `plotWidth`/`xStep` are what the layout settled, so an axis can ask whether its names still fit. */ + layout: { + padding: number; + density: 'compact' | 'normal' | 'airy'; + plotWidth?: number; + xStep?: number; + /** The graphic the caller asked for. Wider than `plotWidth` by the axis gutter. */ + canvasWidth?: number; + }; /** * What the house prints alongside a fit: the quantities it expects to see * stated, and where. Only meaningful where the chart actually fits diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 409f9fdd..b2fc03b7 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -5,7 +5,7 @@ import type { ZeroDecision, ColorSchemeRecommendation } from './semantic-types'; import type { LabelSizingDecision } from './decisions'; import type { SemanticAnnotation, FormatSpec, DomainConstraint, TickConstraint } from './field-semantics'; import type { ColorDecisionResult } from './color-decisions'; -import type { ThemeSpec } from './theme/types'; +import type { GeometryKind, ThemeGeometry, ThemeSpec } from './theme/types'; /** * Core types for the chart engine library. @@ -500,6 +500,17 @@ export interface InstantiateContext { /** User-configured chart properties */ chartProperties?: Record; + /** + * The house's geometry for this chart type, already merged from its common + * profile and any per-chart specialisation, and filtered to the shapes this + * template declares. + * + * Templates read it when the geometry changes what is *built* rather than + * how it is painted — whether a line carries dots, how wide a bar's band is + * — because those cannot be restyled onto a finished spec. + */ + geometry?: ThemeGeometry; + /** Static series metadata (present when input used array-valued encoding) */ staticSeries?: StaticSeriesMetadata; @@ -943,6 +954,17 @@ export interface ChartTemplateDef { /** Optional configurable properties for the chart type */ properties?: ChartPropertyDef[]; + /** + * The geometries this template actually builds. + * + * A theme states geometry once and every chart made of that shape reads it, + * so the template has to say which shapes it makes: a line chart hears + * `line` and `point` and is deaf to `arc`. Geometry a template does not + * declare is dropped and reported rather than carried into a renderer that + * would ignore it. A template that declares nothing keeps the whole profile. + */ + geometryKinds?: GeometryKind[]; + /** * This template draws its own value text instead of using the generic * theme label layer. The public control is still `showValueLabels`; @@ -1245,6 +1267,25 @@ export interface AssembleOptions { * Default: 20. */ defaultBandSize?: number; + /** + * Blend between the scaled base band and the available span per item. + * + * baseSpanStep = baseSpan / N + * capacityStep = availableCapacity / N + * preferredStep = baseSpanStep > baseStep + * ? baseStep × (1 − fit) + capacityStep × fit + * : baseStep + * + * `0` gives fixed-step behavior (Vega-Lite); `1` aims toward the available + * canvas capacity on sparse axes (ECharts, Chart.js, Plotly). The selected + * pitch then passes through normal elastic stretch/compression. Two-banded + * cell grids bypass this policy. Default: 0. + */ + bandStepFit?: number; + /** Explicit X-axis capacity for sparse band fitting. Internal assembler bridge. */ + bandStepFitCapacityX?: number; + /** Explicit Y-axis capacity for sparse band fitting. Internal assembler bridge. */ + bandStepFitCapacityY?: number; /** * Chart-specific **floor** on the per-category band step (at a 300px * baseline canvas), which a house's `layout.bandStep` may grow but not diff --git a/packages/flint-js/src/echarts/assemble.ts b/packages/flint-js/src/echarts/assemble.ts index 64e36bc4..fa67bd77 100644 --- a/packages/flint-js/src/echarts/assemble.ts +++ b/packages/flint-js/src/echarts/assemble.ts @@ -222,6 +222,8 @@ export function assembleECharts(input: ChartAssemblyInput): any { // but adds ~120-160px grid margins on top. A 24px base band gives // bars close to ECharts's native auto-sizing at typical category counts. defaultBandSize: 24, + // ECharts bars natively distribute across the available grid. + bandStepFit: 1, // ECharts fills its grid natively (bars sized by barCategoryGap), so // sparse categories spread out. Allow bands to expand past the base to // match that, capped so a couple of bars don't span the whole canvas. @@ -248,6 +250,8 @@ export function assembleECharts(input: ChartAssemblyInput): any { // (βx, βy) so the entire layout — single plot OR facet grid — stays within // the same budget. Falls back to maxStretch when no ceiling is set. 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); // Default true so that _encodingTooltip is applied and all charts get encoding-style tooltips diff --git a/packages/flint-js/src/plotly/assemble.ts b/packages/flint-js/src/plotly/assemble.ts index 1a1c804a..8b338b07 100644 --- a/packages/flint-js/src/plotly/assemble.ts +++ b/packages/flint-js/src/plotly/assemble.ts @@ -48,6 +48,11 @@ import { plApplyCartesianAxisSpacing, plApplyLayoutToSpec, plApplyTooltips, plAp import { plCombineFacetPanels, niceBounds, type PlotlyFacetPanel } from './facet'; import { normalizeStaticSeries } from '../core/static-series'; import { normalizeChartProperties } from '../core/normalize-properties'; +import { groundTheme, resolveChartDefaults, resolveCompileDefaults } from '../core/theme/ground'; +import { resolveThemeSpec } from '../core/theme/presets'; +import { + realizeThemePlotly, realizeValueLabelsPlotly, plCollectMarkTypes, plCollectPositional, fitPlotlyTitle, +} from './theme'; // --------------------------------------------------------------------------- // Public API @@ -90,10 +95,17 @@ function applyFieldDisplayNames(figure: any, names: Record | und export function assemblePlotly(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); + // `theme_spec` may name a house Flint ships rather than spell one out. + const themeSpec = resolveThemeSpec(input.theme_spec); + // A house may prefer a size, a stretch budget, a facet gap. Those settle + // before anything is measured, and in a fixed order: the chart spec first, + // the theme's presets under it, flint's own defaults under that. + const themePresets = resolveCompileDefaults(themeSpec, input.options); + const housePresets = themeSpec?.compileDefaults; + const sizeCeiling = input.chart_spec.canvasSize ?? housePresets?.canvasSize; + const baseSize = resolveBaseSize(input.chart_spec.baseSize ?? housePresets?.baseSize, sizeCeiling); const canvasSize = baseSize; - const options = input.options ?? {}; + const options = themePresets.options ?? {}; let chartTemplate = plGetTemplateDef(chartType) as ChartTemplateDef; if (!chartTemplate) { throw new Error(`Unknown Plotly chart type: ${chartType}. Use plAllTemplateDefs to see available types.`); @@ -104,9 +116,21 @@ export function assemblePlotly(input: ChartAssemblyInput): any { const normalizedProps = normalizeChartProperties( chartTemplate.properties, input.chart_spec.chartProperties, ); - const chartProperties = normalizedProps.chartProperties; + let chartProperties = normalizedProps.chartProperties; warnings.push(...normalizedProps.warnings); + // A house's rules about the chart itself — points on a line, a bump chart + // left unsmoothed — change what is drawn, not how it is dressed, so they + // are folded in before the pipeline reads the properties. Anything the + // caller stated already is left alone. Mirrors the VL assembler. + if (themeSpec && !chartProperties) chartProperties = {}; + const chartDefaultsReport = chartProperties + ? resolveChartDefaults( + themeSpec, chartType, chartTemplate.properties, + input.chart_spec.chartProperties, chartProperties, + ) + : []; + // ═══════════════════════════════════════════════════════════════════════ // PRE-PHASE: Static Series Normalization // ═══════════════════════════════════════════════════════════════════════ @@ -181,6 +205,8 @@ export function assemblePlotly(input: ChartAssemblyInput): any { : {}; const effectiveOptions: AssembleOptions = { + // Plotly categories natively fill the available plot area. + bandStepFit: 1, // Plotly fills its plot area natively (bars sized by `bargap`), so sparse // categories spread out. Allow bands to expand well past the base size, // matching Plotly's official low-cardinality bar style, but cap it so one @@ -192,8 +218,18 @@ export function assemblePlotly(input: ChartAssemblyInput): any { ...options, ...(declaration.paramOverrides || {}), }; + const houseBandStep = themeSpec?.layout?.bandStep; + const houseBandStepFit = themeSpec?.layout?.bandStepFit; + if (houseBandStep != null && options.defaultBandSize == null) { + effectiveOptions.defaultBandSize = houseBandStep; + } + if (houseBandStepFit != null && options.bandStepFit == null) { + effectiveOptions.bandStepFit = Math.max(0, Math.min(1, houseBandStepFit)); + } 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 { @@ -390,7 +426,12 @@ export function assemblePlotly(input: ChartAssemblyInput): any { hasColHeader: !!colField, hasRowHeader: !!rowField, colHeaderPerRow: wrapColumnOnly, - showLegend: !!channelSemantics.color?.field, + showLegend: !!( + channelSemantics.color?.field + || channelSemantics.group?.field + || channelSemantics.shape?.field + || channelSemantics.strokeDash?.field + ), }); // Apply the shared x-label rotation / font decisions to every panel axis. @@ -418,10 +459,98 @@ export function assemblePlotly(input: ChartAssemblyInput): any { if (chartTemplate.postProcess) chartTemplate.postProcess(figure, instantiateContext); } + // ═══════════════════════════════════════════════════════════════════════ + // HEADLINE + // ═══════════════════════════════════════════════════════════════════════ + // + // Written before theming, because whether the chart has a headline is a + // fact the theme reasons about: a house that omits axis titles is leaning + // on this line to name the measure. The deck rides along on `_deck` — + // Plotly 2.x has no `title.subtitle`, so realization writes it as a second + // styled line rather than as a block of its own. + const headline = input.chart_spec.title?.trim(); + const deck = input.chart_spec.subtitle?.trim(); + if (headline || deck) { + figure.layout.title = { + ...(typeof figure.layout.title === 'object' ? figure.layout.title : {}), + text: headline ?? '', + ...(deck ? { _deck: deck } : {}), + }; + } + + // ═══════════════════════════════════════════════════════════════════════ + // THEME (level 2 grounding → level 3 realization) + // ═══════════════════════════════════════════════════════════════════════ + // + // Runs last: the theme is a style layer over a chart that already fits, so + // it must see the finished figure. Grounding runs whether or not a house + // was named — some design questions (can this chart carry its values, and + // at this density should it?) are Flint's own. Only realization is gated. + const markTypes = plCollectMarkTypes(figure); + const stackedMode = figure.layout?.barmode === 'relative' || figure.layout?.barmode === 'stack' + || (figure.data ?? []).some((t: any) => t?.stackgroup != null); + const design = groundTheme(themeSpec ?? {}, { + chartType, + markChannel: chartTemplate.markCognitiveChannel, + markTypes, + namesOnMarks: (chartProperties as any)?.showSeriesInLabel === true, + channelSemantics, + resolvedTypes: declaration.resolvedTypes as Record | undefined, + axisFlags: declaration.axisFlags, + positional: plCollectPositional(figure, channelSemantics), + layout: layoutResult, + table: values, + canvasSize, + stacked: stackedMode, + partToWhole: markTypes.includes('arc'), + titled: Boolean(figure.layout?.title?.text), + headline: [figure.layout?.title?.text, (input.chart_spec as any)?.subtitle] + .filter((t: unknown) => typeof t === 'string') + .join(' '), + hostSurface: (input.options as any)?.background, + valueLabels: resolvePlValueLabelChoice(chartProperties), + }); + + if (themeSpec) { + // These are semantic inputs to realization, not persistent Plotly + // properties. Sparklines never carry dots; theme-default line points + // may be suppressed when their spacing becomes unreadably dense. + figure._suppressLinePoints = chartTemplate.chart === 'Sparkline' + || input.chart_spec.chartProperties?.showPoints === false; + figure._themeDefaultLinePoints = chartTemplate.chart === 'Line Chart' + && input.chart_spec.chartProperties?.showPoints == null + && chartProperties?.showPoints === true; + const realizeReport = realizeThemePlotly(figure, design, values); + delete figure._suppressLinePoints; + delete figure._themeDefaultLinePoints; + figure._theme = { + id: design.themeId, + report: [...themePresets.report, ...chartDefaultsReport, ...design.report, ...realizeReport], + decisions: design, + }; + } else { + realizeValueLabelsPlotly(figure, design, values); + fitPlotlyTitle(figure); + } + // ═══════════════════════════════════════════════════════════════════════ // RESULT // ═══════════════════════════════════════════════════════════════════════ + // `_width`/`_height` are the size hint a host sizes its container with, + // and `layout.width`/`height` is what Plotly actually draws at. Title + // wrapping, a tall legend and the theme's chrome all grow the layout after + // the hint was written, so re-read the hint off the layout — otherwise a + // host clips the bottom or right off its own chart. + if (Number.isFinite(figure.layout?.width)) { + figure.layout.width = Math.ceil(figure.layout.width); + figure._width = figure.layout.width; + } + if (Number.isFinite(figure.layout?.height)) { + figure.layout.height = Math.ceil(figure.layout.height); + figure._height = figure.layout.height; + } + if (warnings.length > 0) { figure._warnings = warnings; } @@ -443,8 +572,7 @@ export function assemblePlotly(input: ChartAssemblyInput): any { return figure; } -/** Inspect the Plotly legacy (composed) view transformation surface for an input. */ -export function getPlotlyPivot(input: ChartAssemblyInput): PivotSurface | undefined { +/** Inspect the Plotly legacy (composed) view transformation surface for an input. */export function getPlotlyPivot(input: ChartAssemblyInput): PivotSurface | undefined { const spec = assemblePlotly(input); return spec && spec._pivot ? (spec._pivot as PivotSurface) : undefined; } @@ -454,3 +582,18 @@ export function getPlotlyTransform(input: ChartAssemblyInput): TransformSurface const spec = assemblePlotly(input); return spec && spec._transform ? (spec._transform as TransformSurface) : undefined; } + +/** + * The reader's own answer to "print the numbers?". + * + * `showValueLabels` is the control; `showTextLabels` is the older boolean some + * templates still pass, where only `true` is meaningful (`false` means the + * caller never touched it). Mirrors the Vega-Lite assembler. + */ +function resolvePlValueLabelChoice( + chartProperties: Record | undefined, +): 'on' | 'off' | undefined { + const choice = chartProperties?.showValueLabels; + if (typeof choice === 'boolean') return choice ? 'on' : 'off'; + return chartProperties?.showTextLabels === true ? 'on' : undefined; +} diff --git a/packages/flint-js/src/plotly/facet.ts b/packages/flint-js/src/plotly/facet.ts index fe2a6ebe..555472fa 100644 --- a/packages/flint-js/src/plotly/facet.ts +++ b/packages/flint-js/src/plotly/facet.ts @@ -127,6 +127,27 @@ export function plCombineFacetPanels( figure.layout.height = totalH; const seenLegend = new Set(); + let seenColorScale = false; + let scaleMin = Infinity; + let scaleMax = -Infinity; + for (const panel of panels) { + for (const trace of panel.figure?.data ?? []) { + const markerColors = Array.isArray(trace?.marker?.color) ? trace.marker.color : []; + const zValues = Array.isArray(trace?.z) ? trace.z.flat(Infinity) : []; + for (const values of [markerColors, zValues]) { + for (const value of values) { + if (value == null) continue; + const number = Number(value); + if (!Number.isFinite(number)) continue; + scaleMin = Math.min(scaleMin, number); + scaleMax = Math.max(scaleMax, number); + } + } + } + } + const globalScale = Number.isFinite(scaleMin) && Number.isFinite(scaleMax) + ? { min: scaleMin, max: scaleMax } + : null; for (const panel of panels) { const { rowIndex: ri, colIndex: ci } = panel; @@ -144,36 +165,74 @@ export function plCombineFacetPanels( const y0 = 1 - yFrac(yTopPx + panelHeight); const panelLayout = panel.figure?.layout ?? {}; - const xAxis: any = { ...(panelLayout.xaxis ?? {}), domain: [x0, x1], anchor: yRef }; - const yAxis: any = { ...(panelLayout.yaxis ?? {}), domain: [y0, y1], anchor: xRef }; - - // Shared y-domain across panels (mirror CJS: fixed nice range, no - // per-panel autorange). Rangemode would fight an explicit range. - if (sharedYDomain) { - yAxis.range = [sharedYDomain.min, sharedYDomain.max]; - delete yAxis.rangemode; - } - // Only the leftmost column shows y tick labels + title. - if (ci > 0) { - yAxis.showticklabels = false; - delete yAxis.title; - } - // X-axis titles only on the bottom row (a per-panel title would - // collide with the next row's column headers in a single figure). - if (ri < rows - 1) { - delete xAxis.title; + for (const key of ['barmode', 'barnorm'] as const) { + if (panelLayout[key] != null && figure.layout[key] == null) { + figure.layout[key] = panelLayout[key]; + } } + const polarPanel = (panel.figure?.data ?? []).some((trace: any) => + trace?.type === 'barpolar' || trace?.type === 'scatterpolar'); + const polarName = n === 1 ? 'polar' : `polar${n}`; + if (polarPanel) { + figure.layout[polarName] = { + ...(panelLayout.polar ?? {}), + domain: { x: [x0, x1], y: [y0, y1] }, + }; + } else { + const xAxis: any = { ...(panelLayout.xaxis ?? {}), domain: [x0, x1], anchor: yRef }; + const yAxis: any = { ...(panelLayout.yaxis ?? {}), domain: [y0, y1], anchor: xRef }; + + if (sharedYDomain) { + yAxis.range = [sharedYDomain.min, sharedYDomain.max]; + delete yAxis.rangemode; + } + if (ci > 0) { + yAxis.showticklabels = false; + delete yAxis.title; + } + if (ri < rows - 1) delete xAxis.title; - figure.layout[xName] = xAxis; - figure.layout[yName] = yAxis; + figure.layout[xName] = xAxis; + figure.layout[yName] = yAxis; + } for (const trace of panel.figure?.data ?? []) { - const placed: any = { ...trace, xaxis: xRef, yaxis: yRef }; + const placed: any = polarPanel + ? { ...trace, subplot: polarName } + : { ...trace, xaxis: xRef, yaxis: yRef }; + if (globalScale && Array.isArray(placed.marker?.color)) { + placed.marker = { + ...placed.marker, + cmin: globalScale.min, + cmax: globalScale.max, + }; + } + if (globalScale && Array.isArray(placed.z)) { + placed.zmin = globalScale.min; + placed.zmax = globalScale.max; + } + const carriesScale = placed.marker?.showscale === true + || placed.showscale === true + || (placed.type === 'heatmap' && placed.showscale !== false); + if (carriesScale) { + if (seenColorScale) { + if (placed.marker?.showscale === true) placed.marker = { ...placed.marker, showscale: false }; + if (placed.showscale === true || placed.type === 'heatmap') placed.showscale = false; + } else { + seenColorScale = true; + } + } const legendKey = String(placed.name ?? ''); if (legendKey) { - placed.legendgroup = legendKey; - placed.showlegend = !seenLegend.has(legendKey); - seenLegend.add(legendKey); + const dimension = placed._themeRole === 'factored-line-legend-proxy' + ? placed._colorLegendValue ? 'color' : 'dash' + : ''; + const dedupeKey = dimension ? `${dimension}:${legendKey}` : legendKey; + placed.legendgroup = dedupeKey; + placed.showlegend = placed.showlegend === false + ? false + : !seenLegend.has(dedupeKey); + seenLegend.add(dedupeKey); } else { placed.showlegend = false; } diff --git a/packages/flint-js/src/plotly/index.ts b/packages/flint-js/src/plotly/index.ts index ae4acbd2..faf091a8 100644 --- a/packages/flint-js/src/plotly/index.ts +++ b/packages/flint-js/src/plotly/index.ts @@ -25,6 +25,9 @@ export { assemblePlotly, getPlotlyPivot, getPlotlyTransform } from './assemble'; // PL spec instantiation (Phase 2) export { plApplyLayoutToSpec, plApplyTooltips } from './instantiate-spec'; +// PL theme realization (stage 3) +export { realizeThemePlotly, realizeValueLabelsPlotly, plCollectMarkTypes, plCollectPositional, fitPlotlyTitle } from './theme'; + // PL template registry export { plTemplateDefs, diff --git a/packages/flint-js/src/plotly/pie-labels.ts b/packages/flint-js/src/plotly/pie-labels.ts new file mode 100644 index 00000000..a9689e1f --- /dev/null +++ b/packages/flint-js/src/plotly/pie-labels.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** Per-slice Plotly templates: wrap only when name + percentage needs the room. */ +export function pieCategoryPercentTemplates( + labels: any[], + figureWidth: number, + fontSize = 11, +): string[] { + const inlineBudgetPx = Math.min(120, Math.max(72, figureWidth * 0.22)); + return labels.map((label) => { + // Six characters conservatively budgets percentages such as "12.4%". + const combinedPx = (String(label).length + 1 + 6) * fontSize * 0.55; + return combinedPx <= inlineBudgetPx + ? '%{label} %{percent}' + : '%{label}
%{percent}'; + }); +} diff --git a/packages/flint-js/src/plotly/templates/area.ts b/packages/flint-js/src/plotly/templates/area.ts index a80e68ed..2a9fa04c 100644 --- a/packages/flint-js/src/plotly/templates/area.ts +++ b/packages/flint-js/src/plotly/templates/area.ts @@ -76,9 +76,12 @@ export const plAreaChartDef: ChartTemplateDef = { ? extractCategories(table, xField, xCS.ordinalSortOrder) : undefined; - const opacity = Number(chartProperties?.opacity ?? 0.4); const stackMode = chartProperties?.stackMode; const stacked = stackMode !== 'layered'; + // Layered areas overlap, so they have to be see-through. A stack does + // not overlap — a translucent band there only muddies the one beneath + // it and makes neither colour readable. + const opacity = Number(chartProperties?.opacity ?? (colorField && stacked ? 1 : 0.4)); const shape = lineShape(chartProperties?.interpolate); const palette = getPlotlyPalette(ctx, 'color'); @@ -135,6 +138,12 @@ export const plAreaChartDef: ChartTemplateDef = { if (yCS.zero) { yAxisSpec.rangemode = yCS.zero.zero !== false ? 'tozero' : 'normal'; } + // A normalized stack no longer plots the measure, so it must not carry + // the measure's unit either — it reads as a share of the whole. + if (traces[0]?.groupnorm === 'percent') { + yAxisSpec.ticksuffix = '%'; + yAxisSpec.range = [0, 100]; + } const figure: any = { data: traces, diff --git a/packages/flint-js/src/plotly/templates/bar.ts b/packages/flint-js/src/plotly/templates/bar.ts index 433ff34d..4da16baa 100644 --- a/packages/flint-js/src/plotly/templates/bar.ts +++ b/packages/flint-js/src/plotly/templates/bar.ts @@ -24,6 +24,17 @@ const BAR_CORNER_RADIUS: ChartPropertyDef = { min: 0, max: 15, step: 1, defaultValue: 0, }; +function continuousColorValue(value: unknown, temporal: boolean): number | null { + if (value == null || value === '') return null; + const dateLike = temporal || ( + typeof value === 'string' + && !Number.isFinite(Number(value)) + && Number.isFinite(Date.parse(value)) + ); + const number = dateLike ? new Date(value as any).getTime() : Number(value); + return Number.isFinite(number) ? number : null; +} + export const plBarChartDef: ChartTemplateDef = { chart: 'Bar Chart', template: { mark: 'bar', encoding: {} }, @@ -37,7 +48,61 @@ export const plBarChartDef: ChartTemplateDef = { }; }, instantiate: (spec, ctx) => { - const { channelSemantics, table, chartProperties } = ctx; + const { channelSemantics, table, chartProperties, colorDecisions } = ctx; + const xDiscrete = channelSemantics.x?.type === 'nominal' + || channelSemantics.x?.type === 'ordinal'; + const yDiscrete = channelSemantics.y?.type === 'nominal' + || channelSemantics.y?.type === 'ordinal'; + if (xDiscrete && yDiscrete) { + const xField = channelSemantics.x?.field; + const yField = channelSemantics.y?.field; + if (!xField || !yField) return; + const xCategories = extractCategories(table, xField, channelSemantics.x?.ordinalSortOrder); + const yCategories = extractCategories(table, yField, channelSemantics.y?.ordinalSortOrder); + const occupied = new Map(); + for (const row of table) occupied.set(`${String(row[xField])}\0${String(row[yField])}`, row); + const cells = [...occupied.values()]; + const cellSize = Math.max( + 8, + Math.min( + 36, + (ctx.canvasSize.width / Math.max(1, xCategories.length)) * 0.68, + (ctx.canvasSize.height / Math.max(1, yCategories.length)) * 0.68, + ), + ); + Object.assign(spec, { + data: [{ + type: 'scatter', + mode: 'markers', + x: cells.map(row => row[xField]), + y: cells.map(row => row[yField]), + marker: { + symbol: 'square', + size: cellSize, + color: getSeriesColor(getPlotlyPalette(ctx), 0), + }, + showlegend: false, + }], + layout: { + xaxis: { + type: 'category', + categoryorder: 'array', + categoryarray: xCategories, + title: { text: xField }, + }, + yaxis: { + type: 'category', + categoryorder: 'array', + categoryarray: yCategories, + title: { text: yField }, + }, + showlegend: false, + }, + }); + delete spec.mark; + delete spec.encoding; + return; + } const { categoryAxis, valueAxis } = detectAxes(channelSemantics); const catField = channelSemantics[categoryAxis]?.field; @@ -57,6 +122,11 @@ export const plBarChartDef: ChartTemplateDef = { const values = buildCategoryAlignedData(table, catField, valField, categories); const isHorizontal = categoryAxis === 'y'; + const colorField = channelSemantics.color?.field; + const colorDecision = colorDecisions?.color; + const continuousColor = !!colorField + && colorDecision != null + && colorDecision.schemeType !== 'categorical'; const palette = getPlotlyPalette(ctx); const cornerRadius = Number(chartProperties?.cornerRadius ?? 0); @@ -64,6 +134,7 @@ export const plBarChartDef: ChartTemplateDef = { type: 'category' as const, categoryorder: 'array' as const, categoryarray: categories, + ...(isHorizontal ? { autorange: 'reversed' as const } : {}), title: { text: catField }, }; const valCS = channelSemantics[valueAxis]; @@ -74,8 +145,50 @@ export const plBarChartDef: ChartTemplateDef = { rangemode: (includeZero ? 'tozero' : 'normal') as 'tozero' | 'normal', }; - const figure: any = { - data: [{ + const fullTable = ctx.fullTable ?? table; + const colorCategories = colorField && !continuousColor + ? extractCategories(fullTable, colorField, channelSemantics.color?.ordinalSortOrder) + : []; + const temporalColor = channelSemantics.color?.type === 'temporal'; + const categoryRows = categories.map(category => + table.find(row => String(row[catField]) === String(category))); + const allContinuousColors = continuousColor && colorField + ? fullTable + .map(row => continuousColorValue(row[colorField], temporalColor)) + .filter((value): value is number => value != null) + : []; + const traces = continuousColor && colorField + ? [{ + type: 'bar', + name: colorField, + ...(isHorizontal + ? { x: values, y: categories, orientation: 'h' } + : { x: categories, y: values }), + marker: { + color: categoryRows.map(row => + row == null ? null : continuousColorValue(row[colorField], temporalColor)), + colorscale: colorDecision.schemeType === 'diverging' ? 'RdBu' : 'Viridis', + cmin: allContinuousColors.length ? Math.min(...allContinuousColors) : 0, + cmax: allContinuousColors.length ? Math.max(...allContinuousColors) : 1, + showscale: true, + colorbar: { title: { text: colorField } }, + ...(cornerRadius > 0 ? { cornerradius: cornerRadius } : {}), + }, + showlegend: false, + }] + : colorField + ? [...groupBy(table, colorField)].map(([name, rows]) => ({ + type: 'bar', + name, + ...(isHorizontal + ? { x: buildCategoryAlignedData(rows, catField, valField, categories), y: categories, orientation: 'h' } + : { x: categories, y: buildCategoryAlignedData(rows, catField, valField, categories) }), + marker: { + color: getSeriesColor(palette, Math.max(0, colorCategories.indexOf(name))), + ...(cornerRadius > 0 ? { cornerradius: cornerRadius } : {}), + }, + })) + : [{ type: 'bar', name: valField, ...(isHorizontal @@ -85,13 +198,16 @@ export const plBarChartDef: ChartTemplateDef = { color: getSeriesColor(palette, 0), ...(cornerRadius > 0 ? { cornerradius: cornerRadius } : {}), }, - }], + }]; + const figure: any = { + data: traces, layout: { + ...(colorField && !continuousColor ? { barmode: 'stack' } : {}), bargap: 0.2, ...(isHorizontal ? { xaxis: valAxisSpec, yaxis: catAxisSpec } : { xaxis: catAxisSpec, yaxis: valAxisSpec }), - showlegend: false, + showlegend: !!colorField && !continuousColor, }, }; @@ -151,16 +267,19 @@ export const plStackedBarChartDef: ChartTemplateDef = { const traces: any[] = []; if (colorField) { - let i = 0; + const colorCategories = extractCategories( + ctx.fullTable ?? table, + colorField, + channelSemantics.color?.ordinalSortOrder, + ); for (const [name, rows] of groupBy(table, colorField)) { const values = buildCategoryAlignedData(rows, catField, valField, categories); traces.push({ type: 'bar', name, ...(isHorizontal ? { x: values, y: categories, orientation: 'h' } : { x: categories, y: values }), - marker: { color: getSeriesColor(palette, i) }, + marker: { color: getSeriesColor(palette, Math.max(0, colorCategories.indexOf(name))) }, }); - i++; } } else { const values = buildCategoryAlignedData(table, catField, valField, categories); @@ -172,7 +291,13 @@ export const plStackedBarChartDef: ChartTemplateDef = { }); } - const catAxisSpec = { type: 'category' as const, categoryorder: 'array' as const, categoryarray: categories, title: { text: catField } }; + const catAxisSpec = { + type: 'category' as const, + categoryorder: 'array' as const, + categoryarray: categories, + ...(isHorizontal ? { autorange: 'reversed' as const } : {}), + title: { text: catField }, + }; const valCS = channelSemantics[valueAxis]; const includeZero = valCS?.zero ? valCS.zero.zero !== false : true; const valAxisSpec = { title: { text: valField }, rangemode: (includeZero ? 'tozero' : 'normal') as 'tozero' | 'normal' }; @@ -228,7 +353,7 @@ export const plGroupedBarChartDef: ChartTemplateDef = { }; }, instantiate: (spec, ctx) => { - const { channelSemantics, table } = ctx; + const { channelSemantics, table, colorDecisions } = ctx; const { categoryAxis, valueAxis } = detectAxes(channelSemantics); const groupField = channelSemantics.group?.field || channelSemantics.color?.field; @@ -244,6 +369,13 @@ export const plGroupedBarChartDef: ChartTemplateDef = { }); const isHorizontal = categoryAxis === 'y'; const palette = getPlotlyPalette(ctx, 'group'); + const groupChannel = channelSemantics.group?.field ? 'group' : 'color'; + const groupDecision = colorDecisions?.[groupChannel]; + const groupType = channelSemantics[groupChannel]?.type; + const temporalGroup = groupType === 'temporal'; + const continuousGroup = !!groupField + && groupDecision != null + && groupDecision.schemeType !== 'categorical'; // When the group is redundant/nested with the category axis (group == x, // or a 1:1 pairing), no band holds more than one group value — there is @@ -254,17 +386,51 @@ export const plGroupedBarChartDef: ChartTemplateDef = { const degenerateGroup = !!groupField && planBandDodge(table, catField, groupField).maxPerBand <= 1; const traces: any[] = []; - if (groupField) { - let i = 0; + if (continuousGroup && groupField) { + const rowsByCategory = categories.map(category => + table.filter(row => String(row[catField]) === String(category))); + const lanes = Math.max(0, ...rowsByCategory.map(rows => rows.length)); + const colorValues = (ctx.fullTable ?? table) + .map(row => continuousColorValue(row[groupField], temporalGroup)) + .filter((value): value is number => value != null); + const cmin = colorValues.length ? Math.min(...colorValues) : 0; + const cmax = colorValues.length ? Math.max(...colorValues) : 1; + for (let lane = 0; lane < lanes; lane++) { + const laneRows = rowsByCategory.map(rows => rows[lane]); + const values = laneRows.map(row => row == null ? null : Number(row[valField])); + const colors = laneRows.map(row => + row == null ? null : continuousColorValue(row[groupField], temporalGroup)); + traces.push({ + type: 'bar', + offsetgroup: `lane-${lane}`, + showlegend: false, + ...(isHorizontal + ? { x: values, y: categories, orientation: 'h' } + : { x: categories, y: values }), + marker: { + color: colors, + colorscale: groupDecision.schemeType === 'diverging' ? 'RdBu' : 'Viridis', + cmin, + cmax, + showscale: lane === 0, + ...(lane === 0 ? { colorbar: { title: { text: groupField } } } : {}), + }, + }); + } + } else if (groupField) { + const groupCategories = extractCategories( + ctx.fullTable ?? table, + groupField, + channelSemantics[groupChannel]?.ordinalSortOrder, + ); for (const [name, rows] of groupBy(table, groupField)) { const values = buildCategoryAlignedData(rows, catField, valField, categories); traces.push({ type: 'bar', name, ...(isHorizontal ? { x: values, y: categories, orientation: 'h' } : { x: categories, y: values }), - marker: { color: getSeriesColor(palette, i) }, + marker: { color: getSeriesColor(palette, Math.max(0, groupCategories.indexOf(name))) }, }); - i++; } } else { const values = buildCategoryAlignedData(table, catField, valField, categories); @@ -276,7 +442,13 @@ export const plGroupedBarChartDef: ChartTemplateDef = { }); } - const catAxisSpec = { type: 'category' as const, categoryorder: 'array' as const, categoryarray: categories, title: { text: catField } }; + const catAxisSpec = { + type: 'category' as const, + categoryorder: 'array' as const, + categoryarray: categories, + ...(isHorizontal ? { autorange: 'reversed' as const } : {}), + title: { text: catField }, + }; const valCS = channelSemantics[valueAxis]; const includeZero = valCS?.zero ? valCS.zero.zero !== false : true; const valAxisSpec = { title: { text: valField }, rangemode: (includeZero ? 'tozero' : 'normal') as 'tozero' | 'normal' }; @@ -287,7 +459,7 @@ export const plGroupedBarChartDef: ChartTemplateDef = { barmode: degenerateGroup ? 'overlay' : 'group', bargap: 0.2, ...(isHorizontal ? { xaxis: valAxisSpec, yaxis: catAxisSpec } : { xaxis: catAxisSpec, yaxis: valAxisSpec }), - showlegend: !!groupField, + showlegend: !!groupField && !continuousGroup, }, }); delete spec.mark; diff --git a/packages/flint-js/src/plotly/templates/bullet.ts b/packages/flint-js/src/plotly/templates/bullet.ts index 018a8370..554ddb59 100644 --- a/packages/flint-js/src/plotly/templates/bullet.ts +++ b/packages/flint-js/src/plotly/templates/bullet.ts @@ -49,6 +49,7 @@ export const plBulletChartDef: ChartTemplateDef = { const zoneTraces = [0, 1, 2].map(i => ({ type: 'bar', name: `__zone${i}`, + _role: 'context' as const, orientation: 'h' as const, showlegend: false, hoverinfo: 'skip' as const, @@ -90,6 +91,7 @@ export const plBulletChartDef: ChartTemplateDef = { type: 'scatter', mode: 'markers', name: 'Target', + _role: 'reference' as const, x: categories.map(cat => goalOf(cat)), y: categories, showlegend: false, diff --git a/packages/flint-js/src/plotly/templates/connected-scatter.ts b/packages/flint-js/src/plotly/templates/connected-scatter.ts index 060e1dff..a63b0738 100644 --- a/packages/flint-js/src/plotly/templates/connected-scatter.ts +++ b/packages/flint-js/src/plotly/templates/connected-scatter.ts @@ -37,6 +37,7 @@ export const plConnectedScatterDef: ChartTemplateDef = { x: sorted.map((r: any) => r[xField]), y: sorted.map((r: any) => r[yField]), line: { color, shape: 'linear' as const }, + _preserveLineShape: true, marker: { color, size: 7 }, }; }; diff --git a/packages/flint-js/src/plotly/templates/heatmap.ts b/packages/flint-js/src/plotly/templates/heatmap.ts index 186637fb..f1bf073f 100644 --- a/packages/flint-js/src/plotly/templates/heatmap.ts +++ b/packages/flint-js/src/plotly/templates/heatmap.ts @@ -64,7 +64,13 @@ export const plHeatmapDef: ChartTemplateDef = { }], layout: { xaxis: { type: 'category', categoryorder: 'array', categoryarray: xCategories, title: { text: xField } }, - yaxis: { type: 'category', categoryorder: 'array', categoryarray: yCategories, title: { text: yField } }, + yaxis: { + type: 'category', + categoryorder: 'array', + categoryarray: yCategories, + autorange: 'reversed', + title: { text: yField }, + }, showlegend: false, }, }); diff --git a/packages/flint-js/src/plotly/templates/kpi-card.ts b/packages/flint-js/src/plotly/templates/kpi-card.ts index e155ec23..a8f765e8 100644 --- a/packages/flint-js/src/plotly/templates/kpi-card.ts +++ b/packages/flint-js/src/plotly/templates/kpi-card.ts @@ -47,7 +47,7 @@ export const plKpiCardDef: ChartTemplateDef = { // compact fixed footprint and let the grid grow with the tile COUNT // instead of filling the canvas. const TILE_W = 220; - const TILE_H = 150; + const TILE_H = 180; const figWidth = Math.round(cols * TILE_W + gapFrac * (cols - 1) * TILE_W); const figHeight = Math.round(gridRows * TILE_H + gapFrac * (gridRows - 1) * TILE_H); // Font sizes scale with each cell's actual pixel height so a dense @@ -58,6 +58,7 @@ export const plKpiCardDef: ChartTemplateDef = { const traces: any[] = []; const annotations: any[] = []; + const shapes: any[] = []; rows.forEach((row: any, i: number) => { const col = i % cols; const gridRow = Math.floor(i / cols); @@ -69,6 +70,7 @@ export const plKpiCardDef: ChartTemplateDef = { const isNumeric = rawValue != null && rawValue !== '' && Number.isFinite(value); const goal = goalField != null ? Number(row[goalField]) : undefined; const hasGoal = goal != null && Number.isFinite(goal); + const hasProgressGoal = hasGoal && goal > 0; const caption = metricField ? String(row[metricField] ?? '') : valueField; if (isNumeric) { @@ -78,11 +80,64 @@ export const plKpiCardDef: ChartTemplateDef = { value, title: { text: caption, font: { size: captionFontPx } }, number: { font: { size: valueFontPx } }, - domain: { x: [x0, x0 + cellW], y: [y0, y1] }, + domain: { + x: [x0, x0 + cellW], + y: hasProgressGoal + ? [y0 + cellH * 0.24, y1 - cellH * 0.16] + : [y0, y1 - cellH * 0.12], + }, }; if (hasGoal) { indicator.delta = { reference: goal, relative: false, increasing: { color: '#2f855a' }, decreasing: { color: '#c44e52' } }; } + if (hasProgressGoal) { + const barX0 = x0 + cellW * 0.12; + const barX1 = x0 + cellW * 0.88; + const progress = Math.max(0, Math.min(1, value / goal)); + shapes.push( + { + type: 'rect', + xref: 'paper', yref: 'paper', + x0: barX0, x1: barX1, + y0: y0 + cellH * 0.06, y1: y0 + cellH * 0.10, + line: { width: 0 }, + fillcolor: '#e5e7eb', + _role: 'context', + }, + { + type: 'rect', + xref: 'paper', yref: 'paper', + x0: barX0, x1: barX0 + (barX1 - barX0) * progress, + y0: y0 + cellH * 0.06, y1: y0 + cellH * 0.10, + line: { width: 0 }, + fillcolor: '#4f7cff', + _role: 'series', + }, + ); + annotations.push({ + text: `${Math.round((value / goal) * 100)}% of ${goal}`, + x: (x0 + x0 + cellW) / 2, + y: y0 + cellH * 0.13, + xref: 'paper', + yref: 'paper', + xanchor: 'center', + yanchor: 'bottom', + showarrow: false, + font: { size: goalFontPx, color: '#6b7280' }, + }); + } else if (hasGoal) { + annotations.push({ + text: `Goal: ${goal}`, + x: (x0 + x0 + cellW) / 2, + y: y0 + cellH * 0.12, + xref: 'paper', + yref: 'paper', + xanchor: 'center', + yanchor: 'bottom', + showarrow: false, + font: { size: goalFontPx, color: '#6b7280' }, + }); + } traces.push(indicator); } else { // Plotly's `indicator` trace requires a numeric `value` — it has @@ -108,6 +163,7 @@ export const plKpiCardDef: ChartTemplateDef = { data: traces, layout: { annotations, + shapes, // Indicator traces suppress Plotly's default cartesian axes on // their own; a card row with only text annotations (a // pre-formatted string value) has no trace to do that, so the diff --git a/packages/flint-js/src/plotly/templates/line.ts b/packages/flint-js/src/plotly/templates/line.ts index e7fe6d3c..5167c79b 100644 --- a/packages/flint-js/src/plotly/templates/line.ts +++ b/packages/flint-js/src/plotly/templates/line.ts @@ -45,16 +45,20 @@ function lineShape(interpolate: unknown): 'linear' | 'spline' | 'hv' | 'vh' | 'h export const plLineChartDef: ChartTemplateDef = { chart: 'Line Chart', template: { mark: 'line', encoding: {} }, - channels: ['x', 'y', 'color', 'opacity', 'column', 'row'], + channels: ['x', 'y', 'color', 'strokeDash', 'opacity', 'column', 'row'], markCognitiveChannel: 'position', declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, }), instantiate: (spec, ctx) => { - const { channelSemantics, table, chartProperties } = ctx; + const { channelSemantics, table, chartProperties, colorDecisions } = ctx; const xCS = channelSemantics.x; const yCS = channelSemantics.y; const colorField = channelSemantics.color?.field; + const colorType = channelSemantics.color?.type; + const dashField = channelSemantics.strokeDash?.field; + const continuousColor = !!colorField + && (colorType === 'quantitative' || colorType === 'temporal'); if (!xCS?.field || !yCS?.field) return; const xField = xCS.field; @@ -75,7 +79,12 @@ export const plLineChartDef: ChartTemplateDef = { const palette = getPlotlyPalette(ctx, 'color'); const traces: any[] = []; - const makeTrace = (name: string, rows: any[], colorIndex: number) => { + const fullTable = ctx.fullTable ?? table; + const dashCategories = dashField + ? extractCategories(fullTable, dashField, channelSemantics.strokeDash?.ordinalSortOrder) + : []; + const dashes = ['solid', 'dash', 'dot', 'dashdot'] as const; + const makeTrace = (name: string, rows: any[], colorIndex: number, dashIndex = 0) => { const xVals = xIsDiscrete ? categories! : rows.map(r => mapX(r[xField])); @@ -88,20 +97,140 @@ export const plLineChartDef: ChartTemplateDef = { name, x: xVals, y: yVals, - line: { color: getSeriesColor(palette, colorIndex), shape }, + line: { + color: getSeriesColor(palette, colorIndex), + shape, + ...(dashField ? { dash: dashes[dashIndex % dashes.length] } : {}), + }, + ...(colorField ? { + _seriesIndex: colorIndex, + _colorLegendValue: String(rows[0]?.[colorField] ?? ''), + } : {}), + ...(dashField ? { + _dashLegendValue: String(rows[0]?.[dashField] ?? ''), + } : {}), }; }; - if (colorField) { - let i = 0; - for (const [name, rows] of groupBy(table, colorField)) { - traces.push(makeTrace(name, rows, i)); - i++; + if (continuousColor && colorField) { + const toColor = colorType === 'temporal' + ? (value: any) => value == null ? NaN : new Date(value).getTime() + : (value: any) => value == null ? NaN : Number(value); + const allColorVals = fullTable.map(r => toColor(r[colorField])); + const finite = allColorVals.filter(Number.isFinite); + const decision = colorDecisions?.color ?? colorDecisions?.group; + const groups: Array<[string, any[]]> = dashField + ? [...groupBy(table, dashField)] + : [['', table]]; + for (const [dashName, rows] of groups) { + const xVals = xIsDiscrete + ? categories! + : rows.map(r => mapX(r[xField])); + const yVals = xIsDiscrete + ? buildCategoryAlignedData(rows, xField, yField, categories!) + : rows.map(r => (r[yField] == null ? null : r[yField])); + const colorVals = xIsDiscrete + ? buildCategoryAlignedData(rows, xField, colorField, categories!).map(toColor) + : rows.map(r => toColor(r[colorField])); + const dashIndex = dashField ? Math.max(0, dashCategories.indexOf(dashName)) : 0; + traces.push({ + type: 'scatter', + mode: 'lines', + ...(dashField ? { name: dashName } : {}), + x: xVals, + y: yVals, + line: { + color: '#cccccc', + shape, + ...(dashField ? { dash: dashes[dashIndex % dashes.length] } : {}), + }, + hoverinfo: 'skip', + showlegend: !!dashField, + _role: 'context', + }, { + type: 'scatter', + mode: 'markers', + name: colorField, + x: xVals, + y: yVals, + marker: { + color: colorVals, + colorscale: decision?.schemeType === 'diverging' ? 'RdBu' : 'Viridis', + cmin: finite.length ? Math.min(...finite) : 0, + cmax: finite.length ? Math.max(...finite) : 1, + showscale: traces.length === 0, + colorbar: { title: { text: colorField } }, + }, + showlegend: false, + _markerRole: 'secondary', + }); + } + } else if (colorField || dashField) { + const fields = [colorField, dashField].filter((f): f is string => !!f); + const groups = new Map(); + for (const row of table) { + const key = JSON.stringify(fields.map(f => row[f])); + const rows = groups.get(key); + if (rows) rows.push(row); + else groups.set(key, [row]); + } + const colorCategories = colorField + ? extractCategories(fullTable, colorField, channelSemantics.color?.ordinalSortOrder) + : []; + for (const rows of groups.values()) { + const colorValue = colorField ? String(rows[0][colorField]) : ''; + const dashValue = dashField ? String(rows[0][dashField]) : ''; + const colorIndex = colorField ? Math.max(0, colorCategories.indexOf(colorValue)) : 0; + const dashIndex = dashField ? Math.max(0, dashCategories.indexOf(dashValue)) : 0; + const name = [colorValue, dashValue].filter(Boolean).join(' · '); + traces.push(makeTrace(name, rows, colorIndex, dashIndex)); } } else { traces.push(makeTrace(yField, table, 0)); } + if (colorField && dashField && !continuousColor) { + for (const trace of traces) trace.showlegend = false; + const colorCategories = extractCategories( + fullTable, + colorField, + channelSemantics.color?.ordinalSortOrder, + ); + for (const colorValue of colorCategories) { + const source = traces.find(trace => trace._colorLegendValue === colorValue); + if (!source) continue; + traces.push({ + type: 'scatter', + mode: 'lines', + name: colorValue, + x: [null], + y: [null], + line: { color: source.line.color, dash: 'solid', width: 2.5 }, + hoverinfo: 'skip', + showlegend: true, + _seriesIndex: source._seriesIndex, + _colorLegendValue: colorValue, + _themeRole: 'factored-line-legend-proxy', + }); + } + for (const dashValue of dashCategories) { + const source = traces.find(trace => trace._dashLegendValue === dashValue); + if (!source) continue; + traces.push({ + type: 'scatter', + mode: 'lines', + name: dashValue, + x: [null], + y: [null], + line: { color: '#777777', dash: source.line.dash, width: 2.5 }, + hoverinfo: 'skip', + showlegend: true, + _dashLegendValue: dashValue, + _themeRole: 'factored-line-legend-proxy', + }); + } + } + const xAxisSpec: any = { title: { text: xField } }; if (xIsDiscrete) { xAxisSpec.type = 'category'; @@ -121,7 +250,7 @@ export const plLineChartDef: ChartTemplateDef = { layout: { xaxis: xAxisSpec, yaxis: yAxisSpec, - showlegend: !!colorField, + showlegend: continuousColor ? !!dashField : !!(colorField || dashField), }, }; diff --git a/packages/flint-js/src/plotly/templates/pie.ts b/packages/flint-js/src/plotly/templates/pie.ts index 9c238065..ab7fc9d4 100644 --- a/packages/flint-js/src/plotly/templates/pie.ts +++ b/packages/flint-js/src/plotly/templates/pie.ts @@ -13,6 +13,7 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { extractCategories, getPlotlyPalette } from './utils'; import { computeCircumferencePressure, computeEffectiveBarCount } from '../../core/decisions'; +import { pieCategoryPercentTemplates } from '../pie-labels'; function buildPieOption(spec: any, ctx: any, hole: number): void { const { channelSemantics, table, chartProperties } = ctx; @@ -84,6 +85,7 @@ function buildPieOption(spec: any, ctx: any, hole: number): void { // is needed. const n = sortedLabels.length; const hasOutsideLabels = labelType !== 'none'; + const labelsNameSlices = labelType === 'category' || labelType === 'categoryPercent'; const labelMargin = hasOutsideLabels ? Math.min(48, 20 + n) : 12; Object.assign(spec, { @@ -93,6 +95,13 @@ function buildPieOption(spec: any, ctx: any, hole: number): void { values: sortedValues, hole, textinfo: textinfo[labelType] ?? 'label+percent', + ...(labelType === 'categoryPercent' + ? { texttemplate: pieCategoryPercentTemplates(sortedLabels, canvasW) } + : {}), + // Plotly stands inside text on its end as soon as a slice gets + // narrow, which is unreadable and clips against the ring. Held + // horizontal, it shrinks or steps outside instead. + insidetextorientation: 'horizontal', // Let outside slice labels push the margins so a cluster of thin // slices (many tiny wedges crowded together) doesn't clip its // stacked callouts against the canvas edge. @@ -100,7 +109,10 @@ function buildPieOption(spec: any, ctx: any, hole: number): void { marker: { colors: palette, line: { color: '#ffffff', width: 1 } }, }], layout: { - showlegend: true, + // A second copy of every category competes with outside callouts + // for exactly the same edge of the pie. Keep the key only when + // the on-chart text does not already identify each slice. + showlegend: !labelsNameSlices, margin: { t: labelMargin, b: labelMargin, l: 12, r: 12 }, }, _width: canvasW, diff --git a/packages/flint-js/src/plotly/templates/radar.ts b/packages/flint-js/src/plotly/templates/radar.ts index a72b6df3..450b92d0 100644 --- a/packages/flint-js/src/plotly/templates/radar.ts +++ b/packages/flint-js/src/plotly/templates/radar.ts @@ -38,8 +38,31 @@ export const plRadarChartDef: ChartTemplateDef = { const metrics = extractCategories(table, axisField, channelSemantics.x?.ordinalSortOrder); if (metrics.length < 2) return; + // Each metric is measured in its own units — grams of fat against + // milligrams of sodium against a percentage. Sharing one radial scale + // buries every small metric at the centre, so each axis is normalised + // to its own nice ceiling and that ceiling is written into the spoke + // label. This is what the Vega-Lite radar does. + const axisMax: Record = {}; + for (const m of metrics) { + const vals = table + .filter((r: any) => String(r[axisField] ?? '') === m) + .map((r: any) => Number(r[valueField])) + .filter((v: number) => isFinite(v)); + axisMax[m] = niceMax(vals.length > 0 ? Math.max(...vals) : 1); + } + const uniformScale = new Set(Object.values(axisMax)).size === 1; + const showMax = (m: string) => { + const mx = axisMax[m]; + return mx % 1 === 0 ? String(mx) : mx.toFixed(1); + }; + // When every metric already shares a ceiling there is nothing to + // reconcile: one radial scale reads for all of them, and repeating + // "(100)" on every spoke is noise. + const spokeLabel = (m: string) => (uniformScale ? m : `${m} (${showMax(m)})`); + // Close the loop: repeat the first metric at the end so the polygon closes. - const closedMetrics = [...metrics, metrics[0]]; + const closedMetrics = [...metrics, metrics[0]].map(spokeLabel); const meanPerMetric = (rows: any[]) => { const sums = new Map(); @@ -57,13 +80,12 @@ export const plRadarChartDef: ChartTemplateDef = { }; const filled = chartProperties?.filled !== false; - const fillOpacity = Number(chartProperties?.fillOpacity ?? 0.3); + const fillOpacity = Number(chartProperties?.fillOpacity ?? 0.16); const palette = getPlotlyPalette(ctx, 'color'); - // Global radial max across ALL data so facets share one comparable - // scale (a per-facet max would make regions visually incomparable). - const allVals = table.map((r: any) => Number(r[valueField])).filter((v: number) => isFinite(v)); - const radialMax = niceMax(allVals.length > 0 ? Math.max(...allVals) : 1); + // With a shared ceiling the radius stays in data units; otherwise each + // axis is normalised to its own ceiling and the radius is a fraction. + const radialMax = uniformScale ? axisMax[metrics[0]] : 1; // Consistent color per group across every facet cell. const groupOrder = groupField @@ -79,7 +101,11 @@ export const plRadarChartDef: ChartTemplateDef = { polarKey: string, showlegend: boolean, ) => { const values = meanPerMetric(rows); - const closedValues = [...values, values[0]]; + const norm = uniformScale + ? values + : values.map((v, i) => (axisMax[metrics[i]] > 0 ? v / axisMax[metrics[i]] : 0)); + const closedValues = [...norm, norm[0]]; + const closedRaw = [...values, values[0]]; const color = getSeriesColor(palette, idx); return { type: 'scatterpolar', @@ -88,7 +114,10 @@ export const plRadarChartDef: ChartTemplateDef = { showlegend, r: closedValues, theta: closedMetrics, + customdata: closedRaw, + hovertemplate: `%{theta}: %{customdata}${name != null ? `
${name}` : ''}`, subplot: polarKey, + _markerRole: 'secondary', line: { color }, marker: { color }, fill: filled ? ('toself' as const) : undefined, @@ -109,10 +138,27 @@ export const plRadarChartDef: ChartTemplateDef = { const traces: any[] = []; const annotations: any[] = []; - const layout: any = { showlegend: !!groupField }; + // A polar plot carries no cartesian axis furniture, so the default + // margins — room for a y-axis title, tick labels and an x-axis strip — + // are 96px of width and 80px of height spent on nothing. Plotly honours + // `margin` for a polar domain, so every pixel of it comes straight off + // the radius. + const layout: any = { + showlegend: !!groupField, + margin: { t: 12, r: 24, b: 16, l: 24 }, + }; + + // The spoke labels sit outside the circle, so their width is not part + // of the plot — it is chrome, and it has to be *reserved* rather than + // taken out of the radius. Measure the widest one first, then make sure + // the canvas is wide enough to hold two of them either side of a + // legible circle. + const MIN_RADIUS = 80; + const widestSpokePx = Math.min( + 220, + 8 + Math.max(...metrics.map((m) => spokeLabel(m).length)) * 6.2, + ); - // Canvas size first — needed to convert fixed-px label margins into - // domain fractions below. let width: number; let height: number; if (faceted) { @@ -122,12 +168,12 @@ export const plRadarChartDef: ChartTemplateDef = { } else { const p = computeCircumferencePressure(metrics.length, ctx.canvasSize, { minArcPx: 60, - minRadius: 80, + minRadius: MIN_RADIUS, maxStretch: ctx.assembleOptions?.maxStretch, maxStretchX: ctx.assembleOptions?.maxStretchX, maxStretchY: ctx.assembleOptions?.maxStretchY, }); - width = p.canvasW; + width = Math.max(p.canvasW, 2 * widestSpokePx + 2 * MIN_RADIUS + 48); height = p.canvasH; } @@ -135,12 +181,16 @@ export const plRadarChartDef: ChartTemplateDef = { const cellW = (1 - gapFrac * (cols - 1)) / cols; const cellH = (1 - gapFrac * (gridRows - 1)) / gridRows; const titlePad = faceted ? 26 / height : 0; - // The angular (metric) labels sit just outside the circle on all four - // sides. Inset the polar domain inside each cell so a side label - // ("Retention"/"Profit") stays within its OWN cell instead of colliding - // with the neighbouring radar's label across the gap. - const insetXFrac = faceted ? 40 / width : 0; - const insetYFrac = faceted ? 16 / height : 0; + // Inset the polar domain by that reserved strip, so a side label stays + // inside its own cell instead of running off the canvas or into the + // neighbouring radar. In a facet grid the canvas is not grown per cell, + // so there the strip is capped at a fifth of the cell or the radar + // itself vanishes. + const sideLabelPx = faceted + ? Math.min(widestSpokePx, cellW * width * 0.2) + : widestSpokePx; + const insetXFrac = sideLabelPx / width; + const insetYFrac = (faceted ? 16 : 14) / height; const legendShown = new Set(); let cellIndex = 0; @@ -175,7 +225,13 @@ export const plRadarChartDef: ChartTemplateDef = { }, radialaxis: { visible: true, range: [0, radialMax], - showticklabels: !faceted, tickfont: { size: 9 }, + tickmode: 'array', + tickvals: [0.25, 0.5, 0.75, 1].map((level) => level * radialMax), + ticktext: uniformScale ? ['', '', '', showMax(metrics[0])] : undefined, + // Where each axis has its own ceiling the radius is a + // fraction and a number on it would name nothing — the + // spoke labels carry the scale instead. + showticklabels: uniformScale && !faceted, tickfont: { size: 9 }, }, angularaxis: { rotation: 90, direction: 'clockwise', @@ -214,6 +270,6 @@ export const plRadarChartDef: ChartTemplateDef = { { value: false, label: 'Outline only' }, ], } as ChartPropertyDef, - { key: 'fillOpacity', label: 'Opacity', type: 'continuous', min: 0.05, max: 0.8, step: 0.05, defaultValue: 0.3 } as ChartPropertyDef, + { key: 'fillOpacity', label: 'Opacity', type: 'continuous', min: 0.05, max: 0.8, step: 0.05, defaultValue: 0.16 } as ChartPropertyDef, ], }; diff --git a/packages/flint-js/src/plotly/templates/ranged-dot.ts b/packages/flint-js/src/plotly/templates/ranged-dot.ts index b58f989f..f4709940 100644 --- a/packages/flint-js/src/plotly/templates/ranged-dot.ts +++ b/packages/flint-js/src/plotly/templates/ranged-dot.ts @@ -77,7 +77,13 @@ export const plRangedDotPlotDef: ChartTemplateDef = { }); } - const catAxisSpec = { type: 'category' as const, categoryorder: 'array' as const, categoryarray: categories, title: { text: catField } }; + const catAxisSpec = { + type: 'category' as const, + categoryorder: 'array' as const, + categoryarray: categories, + ...(catAxis === 'y' ? { autorange: 'reversed' as const } : {}), + title: { text: catField }, + }; const contAxisSpec = { title: { text: contField } }; Object.assign(spec, { diff --git a/packages/flint-js/src/plotly/templates/rose.ts b/packages/flint-js/src/plotly/templates/rose.ts index f841ca9a..2b4a1edf 100644 --- a/packages/flint-js/src/plotly/templates/rose.ts +++ b/packages/flint-js/src/plotly/templates/rose.ts @@ -34,14 +34,15 @@ export const plRoseChartDef: ChartTemplateDef = { const colorField = channelSemantics.color?.field; if (!catField || !valField) return; - let categories = extractCategories(table, catField, channelSemantics.x?.ordinalSortOrder); + const fullTable = ctx.fullTable ?? table; + let categories = extractCategories(fullTable, catField, channelSemantics.x?.ordinalSortOrder); if (categories.length === 0) return; const sortSlices = chartProperties?.sortSlices; if (sortSlices === 'descending' || sortSlices === 'ascending') { const totals = new Map(); for (const c of categories) totals.set(c, 0); - for (const row of table) { + for (const row of fullTable) { const c = String(row[catField] ?? ''); if (totals.has(c)) totals.set(c, totals.get(c)! + (Number(row[valField]) || 0)); } @@ -61,16 +62,19 @@ export const plRoseChartDef: ChartTemplateDef = { const palette = getPlotlyPalette(ctx, 'color'); const traces: any[] = []; if (colorField) { - let i = 0; + const colorCategories = extractCategories( + fullTable, + colorField, + channelSemantics.color?.ordinalSortOrder, + ); for (const [name, rows] of groupBy(table, colorField)) { traces.push({ type: 'barpolar', name, r: sumPerCategory(rows), theta: categories, - marker: { color: getSeriesColor(palette, i) }, + marker: { color: getSeriesColor(palette, Math.max(0, colorCategories.indexOf(name))) }, }); - i++; } } else { traces.push({ diff --git a/packages/flint-js/src/plotly/templates/scatter.ts b/packages/flint-js/src/plotly/templates/scatter.ts index 1e9fecbc..3e73eeb3 100644 --- a/packages/flint-js/src/plotly/templates/scatter.ts +++ b/packages/flint-js/src/plotly/templates/scatter.ts @@ -10,10 +10,35 @@ */ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; -import { groupBy, getPlotlyPalette, getSeriesColor } from './utils'; +import { extractCategories, groupBy, getPlotlyPalette, getSeriesColor } from './utils'; import { makeCartesianPivot } from '../../core/pivot'; /** Compute a reasonable marker diameter based on canvas area and point count. */ +/** + * Per-point diameters for a bound `size` channel. + * + * A reader compares bubbles by *area*, so the value is mapped onto an area and + * the diameter taken back out of it — mapping straight onto the diameter makes + * a twice-as-big number look four times as big. Plotly's own `sizeref` cannot + * do this: it divides a data value, so it needs the raw numbers, and it would + * fight the theme's size range, which rescales drawn pixels. + */ +function sizeScale(table: any[], field: string): (row: any) => number { + const values = table + .map((r: any) => Number(r[field])) + .filter((v: number) => Number.isFinite(v)); + const min = values.length ? Math.min(...values) : 0; + const max = values.length ? Math.max(...values) : 1; + const MIN_AREA = 40; + const MAX_AREA = 900; + return (row: any) => { + const v = Number(row[field]); + if (!Number.isFinite(v)) return 2 * Math.sqrt(MIN_AREA / Math.PI); + const t = max > min ? (v - min) / (max - min) : 1; + return 2 * Math.sqrt((MIN_AREA + t * (MAX_AREA - MIN_AREA)) / Math.PI); + }; +} + function computeMarkerSize(width: number, height: number, pointCount: number): number { const canvasArea = width * height; const areaPerPoint = canvasArea / Math.max(1, pointCount); @@ -25,13 +50,14 @@ function computeMarkerSize(width: number, height: number, pointCount: number): n export const plScatterPlotDef: ChartTemplateDef = { chart: 'Scatter Plot', template: { mark: 'circle', encoding: {} }, - channels: ['x', 'y', 'color', 'size', 'opacity', 'column', 'row'], + channels: ['x', 'y', 'color', 'size', 'shape', 'opacity', 'column', 'row'], markCognitiveChannel: 'position', instantiate: (spec, ctx) => { const { channelSemantics, table, chartProperties, colorDecisions } = ctx; const xField = channelSemantics.x?.field; const yField = channelSemantics.y?.field; const colorField = channelSemantics.color?.field; + const shapeField = channelSemantics.shape?.field; const colorType = channelSemantics.color?.type; const isTemporalColor = colorType === 'temporal'; const isContinuousColor = !!colorField && (colorType === 'quantitative' || isTemporalColor); @@ -39,7 +65,11 @@ export const plScatterPlotDef: ChartTemplateDef = { if (!xField || !yField) return; const opacity = Number(chartProperties?.opacity ?? 1); + const sizeField = channelSemantics.size?.field; + const sizeOf = sizeField ? sizeScale(table, sizeField) : null; const traces: any[] = []; + const symbols = ['circle', 'square', 'triangle-up', 'diamond', 'cross', 'x', 'star']; + const fullTable = ctx.fullTable ?? table; if (isContinuousColor && colorField) { // A quantitative/temporal color channel is a numeric scale, not a @@ -49,30 +79,55 @@ export const plScatterPlotDef: ChartTemplateDef = { const toColorVal = isTemporalColor ? (v: any) => (v != null ? new Date(v).getTime() : NaN) : (v: any) => (v != null ? Number(v) : NaN); - const colorVals = table.map((r: any) => toColorVal(r[colorField])).filter((v: number) => !isNaN(v)); + const colorVals = fullTable + .map((r: any) => toColorVal(r[colorField])) + .filter((v: number) => !isNaN(v)); const cmin = colorVals.length ? Math.min(...colorVals) : 0; const cmax = colorVals.length ? Math.max(...colorVals) : 1; const decision = colorDecisions?.color ?? colorDecisions?.group; const diverging = decision?.schemeType === 'diverging'; - traces.push({ - type: 'scatter', - mode: 'markers', - name: colorField, - x: table.map((r: any) => r[xField]), - y: table.map((r: any) => r[yField]), - marker: { - color: table.map((r: any) => toColorVal(r[colorField])), - colorscale: diverging ? 'RdBu' : 'Viridis', - cmin, cmax, - showscale: true, - colorbar: { title: { text: colorField } }, - opacity, - line: { color: '#ffffff', width: 0.5 }, - }, - }); + const shapeCategories = shapeField + ? extractCategories(fullTable, shapeField, channelSemantics.shape?.ordinalSortOrder) + : []; + const groups: Array<[string, any[]]> = shapeField + ? [...groupBy(table, shapeField)] + : [['', table]]; + for (const [shapeName, rows] of groups) { + const shapeIndex = shapeField ? Math.max(0, shapeCategories.indexOf(shapeName)) : 0; + traces.push({ + type: 'scatter', + mode: 'markers', + name: shapeField ? shapeName : colorField, + x: rows.map((r: any) => r[xField]), + y: rows.map((r: any) => r[yField]), + marker: { + color: rows.map((r: any) => toColorVal(r[colorField])), + colorscale: diverging ? 'RdBu' : 'Viridis', + cmin, cmax, + showscale: traces.length === 0, + ...(traces.length === 0 ? { colorbar: { title: { text: colorField } } } : {}), + ...(shapeField ? { symbol: symbols[shapeIndex % symbols.length] } : {}), + opacity, + ...(sizeOf ? { size: rows.map((r: any) => sizeOf(r)) } : {}), + line: { color: '#ffffff', width: 0.5 }, + }, + showlegend: !!shapeField, + }); + } } else { const palette = getPlotlyPalette(ctx, 'color'); - const makeTrace = (name: string | undefined, rows: any[], colorIndex: number) => ({ + const colorCategories = colorField + ? extractCategories(fullTable, colorField, channelSemantics.color?.ordinalSortOrder) + : []; + const shapeCategories = shapeField + ? extractCategories(fullTable, shapeField, channelSemantics.shape?.ordinalSortOrder) + : []; + const makeTrace = ( + name: string | undefined, + rows: any[], + colorIndex: number, + shapeIndex: number, + ) => ({ type: 'scatter', mode: 'markers', ...(name != null ? { name } : {}), @@ -80,19 +135,32 @@ export const plScatterPlotDef: ChartTemplateDef = { y: rows.map(r => r[yField]), marker: { color: getSeriesColor(palette, colorIndex), + ...(shapeField ? { symbol: symbols[shapeIndex % symbols.length] } : {}), opacity, + ...(sizeOf ? { size: rows.map(r => sizeOf(r)) } : {}), line: { color: '#ffffff', width: 0.5 }, }, }); - if (colorField) { - let i = 0; - for (const [name, rows] of groupBy(table, colorField)) { - traces.push(makeTrace(name, rows, i)); - i++; + if (colorField || shapeField) { + const fields = [colorField, shapeField].filter((f): f is string => !!f); + const groups = new Map(); + for (const row of table) { + const key = JSON.stringify(fields.map(f => row[f])); + const rows = groups.get(key); + if (rows) rows.push(row); + else groups.set(key, [row]); + } + for (const rows of groups.values()) { + const colorValue = colorField ? String(rows[0][colorField]) : ''; + const shapeValue = shapeField ? String(rows[0][shapeField]) : ''; + const colorIndex = colorField ? Math.max(0, colorCategories.indexOf(colorValue)) : 0; + const shapeIndex = shapeField ? Math.max(0, shapeCategories.indexOf(shapeValue)) : 0; + const name = [colorValue, shapeValue].filter(Boolean).join(' · '); + traces.push(makeTrace(name, rows, colorIndex, shapeIndex)); } } else { - traces.push(makeTrace(undefined, table, 0)); + traces.push(makeTrace(undefined, table, 0, 0)); } } @@ -110,7 +178,7 @@ export const plScatterPlotDef: ChartTemplateDef = { layout: { xaxis: xAxisSpec, yaxis: yAxisSpec, - showlegend: !!colorField && !isContinuousColor, + showlegend: !!shapeField || (!!colorField && !isContinuousColor), }, }; diff --git a/packages/flint-js/src/plotly/templates/slope.ts b/packages/flint-js/src/plotly/templates/slope.ts index bdecb850..3ccc15d3 100644 --- a/packages/flint-js/src/plotly/templates/slope.ts +++ b/packages/flint-js/src/plotly/templates/slope.ts @@ -67,8 +67,10 @@ export const plSlopeChartDef: ChartTemplateDef = { traces.push(makeTrace(undefined, table, 0)); } - const yAxisSpec: any = { title: { text: yField } }; - if (channelSemantics.y?.zero) yAxisSpec.rangemode = channelSemantics.y.zero.zero !== false ? 'tozero' : 'normal'; + // A slopegraph compares change between its two columns; anchoring the + // value axis at zero compresses the slopes and contradicts the + // data-fitted framing used by the other backends. + const yAxisSpec: any = { title: { text: yField }, rangemode: 'normal' }; Object.assign(spec, { data: traces, diff --git a/packages/flint-js/src/plotly/templates/sparkline.ts b/packages/flint-js/src/plotly/templates/sparkline.ts index a26c067d..f5d1c19b 100644 --- a/packages/flint-js/src/plotly/templates/sparkline.ts +++ b/packages/flint-js/src/plotly/templates/sparkline.ts @@ -212,6 +212,7 @@ export const plSparklineDef: ChartTemplateDef = { if (refY != null) { traces.push({ type: 'scatter', mode: 'lines', + _role: 'reference', xaxis: xRef, yaxis: yRef, x: [seriesRows[0].x, seriesRows[seriesRows.length - 1].x], y: [refY, refY], diff --git a/packages/flint-js/src/plotly/templates/waterfall.ts b/packages/flint-js/src/plotly/templates/waterfall.ts index 5753b9bb..3c691d5e 100644 --- a/packages/flint-js/src/plotly/templates/waterfall.ts +++ b/packages/flint-js/src/plotly/templates/waterfall.ts @@ -91,6 +91,7 @@ export const plWaterfallChartDef: ChartTemplateDef = { text: showLabels ? values.map(v => (v > 0 ? '+' : '') + v) : undefined, textposition: showLabels ? 'outside' as const : undefined, ...(showLabels ? { cliponaxis: false } : {}), + showlegend: false, }], layout: { xaxis: { type: 'category', categoryorder: 'array', categoryarray: categories, title: { text: xField } }, diff --git a/packages/flint-js/src/plotly/theme.ts b/packages/flint-js/src/plotly/theme.ts new file mode 100644 index 00000000..3e930e34 --- /dev/null +++ b/packages/flint-js/src/plotly/theme.ts @@ -0,0 +1,2604 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * ============================================================================= + * STAGE 3: THEME REALIZATION — Plotly backend + * ============================================================================= + * + * Takes the backend-neutral {@link DesignDecisions} produced by stage 2 + * (`core/theme/ground.ts`) and writes them into a Plotly figure + * (`{ data: trace[], layout }`). + * + * The stage boundary is the same one the Vega-Lite realizer obeys + * (design-docs/04-experiment.md §2): this file may *realize* or *approximate* + * what stage 2 decided, and must report every approximation; it may not decide + * anything itself. Any `if (chartType === …)` here would be a bug in stage 2. + * + * What is different about Plotly, and therefore what this file has to fake: + * + * - There is no `config` and no scale/mark separation. Style lives on each + * trace, so a decision about "the series" is a walk over `figure.data`. + * - Bars are sized by `layout.bargap`, not by a mark width, so band occupancy + * is one number for the whole figure rather than per mark. + * - Text on marks is a trace property (`text` + `textposition`), and Plotly + * places inside/outside labels itself — the geometry stage 2 computed + * (`insideMinValue`/`outsideMaxValue`) is handed over as `textposition: + * 'auto'` rather than realized as two filtered layers. + * - A figure may hold several subplot axis pairs (`xaxis2`, `yaxis3`, …) for + * facets and composites. Every axis pass walks all of them. + * ============================================================================= + */ + +import type { + DesignDecisions, + ThemeReport, + ResolvedAxis, + ResolvedText, +} from '../core/theme/types'; +import { parseColor, toHex, mixHex, isDarkSurface, contrastingInk, sampleRamp } from '../core/theme/presence'; +import { pieCategoryPercentTemplates } from './pie-labels'; + +type Say = (path: string, message: string) => void; + +// --------------------------------------------------------------------------- +// Facts read off the figure (never styles — see the stage boundary above) +// --------------------------------------------------------------------------- + +/** Plotly trace types that carry data values (as opposed to chrome). */ +const CHROME_TRACES = new Set(['table']); + +/** + * The mark family a Plotly trace belongs to, named in the vocabulary stage 2 + * speaks (`bar`, `line`, `point`, `area`, `arc`, `rect`, `boxplot`, `text`). + * + * Grounding reasons about mark *families* — whether the chart draws a + * connected path, whether a mark has an inside a label could sit in. Plotly + * says `scatter` for four different families and distinguishes them by `mode`, + * so the family is reconstructed here. + */ +export function markFamilies(trace: any): string[] { + const t = String(trace?.type ?? 'scatter'); + const out: string[] = []; + switch (t) { + case 'bar': + case 'histogram': + case 'funnel': + case 'waterfall': + out.push('bar'); + break; + case 'pie': + out.push('arc'); + break; + case 'heatmap': + case 'histogram2d': + case 'contour': + out.push('rect'); + break; + case 'box': + out.push('boxplot'); + break; + case 'violin': + out.push('boxplot', 'area'); + break; + case 'choropleth': + case 'choroplethmapbox': + out.push('geoshape'); + break; + case 'indicator': + out.push('text'); + break; + case 'scatter': + case 'scattergl': + case 'scatterpolar': + case 'scattergeo': + case 'barpolar': { + if (t === 'barpolar') { + out.push('arc'); + break; + } + const mode = String(trace?.mode ?? 'lines'); + // A trace in a `stackgroup` is filled by Plotly whether or not it + // says so — `fill` defaults to `tonexty` inside a stack. Reading + // only the explicit key types a stacked area as a bare line, and + // then nothing that paints an area ever reaches it. + const stackFilled = trace?.stackgroup != null && trace?.fill !== 'none'; + const filled = (trace?.fill && trace.fill !== 'none') || stackFilled; + if (filled) out.push('area'); + if (mode.includes('lines')) out.push('line'); + if (mode.includes('markers')) out.push('point'); + if (mode.includes('text')) out.push('text'); + if (!out.length) out.push('line'); + break; + } + default: + out.push(t); + } + return out; +} + +/** Every mark family present anywhere in the figure. Used to ground before realizing. */ +export function plCollectMarkTypes(figure: any): string[] { + const seen = new Set(); + for (const trace of figure?.data ?? []) { + if (CHROME_TRACES.has(String(trace?.type))) continue; + for (const m of markFamilies(trace)) seen.add(m); + } + return [...seen]; +} + +/** Plotly axis `type` → the encoding type stage 2 reasons about. */ +function axisEncodingType(type: string | undefined): string | undefined { + switch (type) { + case 'category': + return 'nominal'; + case 'date': + return 'temporal'; + case 'linear': + case 'log': + return 'quantitative'; + default: + return undefined; + } +} + +/** + * The facts about position and series that stage 2 is allowed to consult. + * + * Templates are free to name their semantic channels anything (a candlestick + * has no `y`), so `channelSemantics` can be silent about the axes a reader + * will actually see. The axis `type` and the field its title names are facts + * about the chart, not style choices. + */ +export function plCollectPositional( + figure: any, + channelSemantics: Record = {}, +): { + x?: { type?: string; field?: string }; + y?: { type?: string; field?: string }; + color?: { type?: string; field?: string }; + stacked?: boolean; +} { + const layout = figure?.layout ?? {}; + const out: any = {}; + for (const ch of ['x', 'y'] as const) { + const axis = layout[`${ch}axis`]; + const type = axisEncodingType(axis?.type) + ?? (channelSemantics[ch]?.type as string | undefined); + const field = channelSemantics[ch]?.field + ?? (typeof axis?.title?.text === 'string' ? axis.title.text : undefined); + if (type || field) out[ch] = { type, field }; + } + const colorCS = channelSemantics.color; + if (colorCS?.field) out.color = { type: colorCS.type, field: colorCS.field }; + + const barmode = String(layout.barmode ?? ''); + out.stacked = barmode === 'stack' || barmode === 'relative' + || (figure?.data ?? []).some((t: any) => t?.stackgroup != null); + return out; +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +function axisKeys(layout: any, ch: 'x' | 'y'): string[] { + return Object.keys(layout ?? {}).filter((k) => new RegExp(`^${ch}axis\\d*$`).test(k)); +} + +function fontOf(t: ResolvedText | undefined, fallbackFamily?: string): any { + if (!t) return undefined; + const f: any = {}; + if (t.font ?? fallbackFamily) f.family = t.font ?? fallbackFamily; + if (t.fontSize != null) f.size = t.fontSize; + if (t.color) f.color = t.color; + // Plotly has no font-weight/style on most font objects (2.x); bold and + // italic are carried as markup on the string instead — see `styleText`. + return f; +} + +/** True when this text role asks for weight or slope Plotly cannot set as a property. */ +function needsMarkup(t: ResolvedText | undefined): { bold: boolean; italic: boolean } { + const w = t?.fontWeight; + const bold = w === 'bold' || (typeof w === 'number' && w >= 600); + return { bold, italic: t?.fontStyle === 'italic' }; +} + +function styleText(s: string, t: ResolvedText | undefined): string { + const { bold, italic } = needsMarkup(t); + let out = s; + if (bold) out = `${out}`; + if (italic) out = `${out}`; + return out; +} + +/** A dash array in px → Plotly's `dash` string. */ +function dashOf(dash: number[] | undefined): string | undefined { + if (!dash || !dash.length) return undefined; + return dash.map((n) => `${n}px`).join(','); +} + +function isLiteralColor(v: unknown): v is string { + return typeof v === 'string' && /^(#|rgb|hsl)/i.test(v.trim()); +} + +/** Distinct values a field takes in the rows behind the chart. */ +function distinctCount(table: any[], field: string | undefined): number { + if (!field) return 0; + const seen = new Set(); + for (const row of table) if (row?.[field] != null) seen.add(row[field]); + return seen.size; +} + +/** + * Traces that draw the data, as opposed to context the template hard-coded. + * + * A trace whose colour is a literal the template chose *beside* colour-encoded + * traces is context — a band, a target, a reference — and it keeps its role + * (gap 22 in the Vega-Lite log). Here the same test is cruder because Plotly + * has no encodings: a trace is context when the template said so by naming it + * outside the legend (`showlegend: false` on a figure that has a legend) *and* + * it carries no name. + */ +function isContextTrace(trace: any): boolean { + return trace?._role === 'context' || trace?.hoverinfo === 'skip' && !trace?.name; +} + +/** A reference mark states a target or a threshold: furniture, not a series. */ +function isReferenceTrace(trace: any): boolean { + return trace?._role === 'reference'; +} + +function isLegendProxy(trace: any): boolean { + return typeof trace?._themeRole === 'string' + && trace._themeRole.endsWith('legend-proxy'); +} + +function dataTraces(figure: any): any[] { + return (figure?.data ?? []).filter( + (t: any) => t && !CHROME_TRACES.has(String(t.type)) + && !isContextTrace(t) && !isReferenceTrace(t) && !isLegendProxy(t), + ); +} + +/** + * Bands of context and reference ticks are drawn in fixed greys and blacks by + * the templates, which reads as a stack of light bars on a dark card. Each is + * restated against the house surface, keeping its relative weight. + */ +function applyFurnitureTraces(figure: any, d: DesignDecisions): void { + const surface = d.surface.plot ?? d.surface.canvas; + for (const shape of figure.layout?.shapes ?? []) { + if (shape?._role === 'series') shape.fillcolor = d.series.single; + if (shape?._role === 'context') { + shape.fillcolor = mixHex(surface, d.text.primary, 0.12); + } + } + for (const trace of figure.data ?? []) { + if (isReferenceTrace(trace)) { + const line = trace.marker?.line; + if (line) trace.marker = { ...trace.marker, line: { ...line, color: d.text.primary } }; + else if (trace.marker) trace.marker = { ...trace.marker, color: d.text.primary }; + // A rule is read against the marks, not with them: it takes the + // weight of a grid line rather than the weight of a series. + if (trace.line) { + trace.line = { + ...trace.line, + color: trace.line.dash ? mixHex(surface, d.text.primary, 0.45) : d.text.primary, + }; + } + continue; + } + if (!isContextTrace(trace)) continue; + const colour = trace.marker?.color; + if (typeof colour !== 'string') continue; + const rgb = parseColor(colour); + if (!rgb) continue; + const l = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; + const weight = Math.min(0.25, Math.max(0.04, (1 - l) * 1.6)); + trace.marker = { ...trace.marker, color: mixHex(surface, d.text.primary, weight) }; + } +} + +// --------------------------------------------------------------------------- +// Entry points +// --------------------------------------------------------------------------- + +export function realizeThemePlotly(figure: any, d: DesignDecisions, table: any[] = []): ThemeReport[] { + const report: ThemeReport[] = []; + const say: Say = (path, message) => report.push({ stage: 'realize', path, message }); + if (!figure) return report; + figure.layout ??= {}; + + applySurface(figure, d); + applyGeoSurface(figure, d); + applyFurnitureTraces(figure, d); + const titleH = applyTypography(figure, d); + applyAxes(figure, d, table, say); + applyMarks(figure, d, table, say); + applySeriesInk(figure, d, table, say); + const legendH = applyLegend(figure, d, say); + applyFacetChrome(figure, d); + applyDataLabels(figure, d, table, say); + layoutTopChrome(figure, d, titleH, legendH); + + return report; +} + +/** + * Print the value labels, and nothing else. + * + * The mirror of `realizeValueLabelsVegaLite`: value labels belong to Flint, not + * to a house, so an untheme'd Plotly chart still gets its numbers without also + * getting somebody's ink and type. + */ +export function realizeValueLabelsPlotly(figure: any, d: DesignDecisions, table: any[] = []): ThemeReport[] { + const report: ThemeReport[] = []; + const say: Say = (path, message) => report.push({ stage: 'realize', path, message }); + if (!figure) return report; + applyDataLabels(figure, d, table, say); + return report; +} + +// --------------------------------------------------------------------------- +// Surface & typography +// --------------------------------------------------------------------------- + +function applySurface(figure: any, d: DesignDecisions): void { + const layout = figure.layout; + layout.paper_bgcolor = d.surface.canvas; + layout.plot_bgcolor = d.surface.plot ?? d.surface.canvas; + + // Plotly draws no view border; a frame is the four axis lines mirrored. + if (d.frame.show) { + for (const ch of ['x', 'y'] as const) { + for (const key of axisKeys(layout, ch)) { + const ax = (layout[key] ??= {}); + ax.showline = true; + ax.linecolor = d.frame.color; + ax.linewidth = d.frame.width; + ax.mirror = true; + } + } + } +} + +/** + * Type, and the title block. + * + * Returns the height the title block needs, in px. Plotly does not wrap a + * title and does not reserve room for one that overruns the figure, so both + * are done here: the headline is broken to the width it has, and the height + * that costs is handed to {@link layoutTopChrome}. + */ +/** + * A card's delta is a verdict, so it takes the house's status inks. Plotly's + * stock green and red are stated for a white card and go muddy on a dark one, + * so a house that names no status inks gets them lifted until they read. + */ +function applyDeltaInk(trace: any, d: DesignDecisions): void { + if (!trace.delta) return; + const bg = d.surface.plot ?? d.surface.canvas; + const lift = (ink: string) => (isDarkSurface(bg) ? mixHex(ink, '#ffffff', 0.35) : ink); + const up = d.series.status?.positive ?? lift('#2f8f4e'); + const down = d.series.status?.negative ?? lift('#c0392b'); + trace.delta = { + ...trace.delta, + increasing: { ...(trace.delta.increasing ?? {}), color: up }, + decreasing: { ...(trace.delta.decreasing ?? {}), color: down }, + font: { ...(trace.delta.font ?? {}), ...(d.font ? { family: d.font } : {}) }, + }; +} + +function applyTypography(figure: any, d: DesignDecisions): number { + const layout = figure.layout; + layout.font = { + ...(layout.font ?? {}), + ...(d.font ? { family: d.font } : {}), + color: d.text.primary, + }; + + // A card states its own caption and number inside the trace. + for (const trace of figure.data ?? []) { + if (trace?.type !== 'indicator') continue; + trace.title = { ...(trace.title ?? {}), font: { ...(trace.title?.font ?? {}), ...(d.font ? { family: d.font } : {}), color: d.text.secondary } }; + trace.number = { ...(trace.number ?? {}), font: { ...(trace.number?.font ?? {}), ...(d.font ? { family: d.font } : {}), color: d.text.primary } }; + applyDeltaInk(trace, d); + } + + restateNeutralAnnotations(layout, d); + + const title = layout.title; + const headlineText = typeof title === 'string' ? title : title?.text; + if (!headlineText) return 0; + + // Plotly 2.x has no `title.subtitle`, so a deck is carried as a second line + // of the title with its own inline type. + const h = d.title.headline; + const deckText = (title as any)?._deck as string | undefined; + const deck = d.title.deck; + const width = Number(layout.width) || 400; + + const headlineSize = h.fontSize ?? 16; + const headLines = wrapToWidth( + String(headlineText).replace(/
/g, ' '), + width - 8, + headlineSize, + needsMarkup(h).bold, + ); + const lines = [styleText(headLines.join('
'), h)]; + + let height = 8 + titleBlockHeight(headLines.length, headlineSize) + 6; + if (deckText) { + const size = deck.fontSize ?? 12; + const color = deck.color ?? d.text.secondary; + const deckLines = wrapToWidth(deckText, width - 8, size, needsMarkup(deck).bold); + const style = [ + `font-size:${size}px`, + `color:${color}`, + ...(deck.font ? [`font-family:${deck.font}`] : []), + ].join(';'); + lines.push(`${styleText(deckLines.join('
'), deck)}
`); + height += titleBlockHeight(deckLines.length, size) + d.title.deckPadding; + } + + const anchor = d.title.anchor; + layout.title = { + text: lines.join('
'), + font: { ...fontOf(h, d.font), size: headlineSize }, + x: anchor === 'start' ? 0.005 : anchor === 'end' ? 0.995 : 0.5, + xanchor: anchor === 'start' ? 'left' : anchor === 'end' ? 'right' : 'center', + xref: 'container', + y: titleY(layout, headLines.length + (deckText ? 1 : 0), headlineSize), + yanchor: 'top', + yref: 'container', + }; + return height + d.title.offset; +} + +/** + * Restate the template's own grey text in the house's inks. + * + * A bar table writes its category names, headers and totals as annotations in + * fixed greys, which are invisible on a dark house. A *grey* is furniture — it + * was chosen to be quiet, not to mean something — so it is re-read as a text + * role by its lightness and rewritten. A coloured annotation is data and is + * left alone. + */ +function restateNeutralAnnotations(layout: any, d: DesignDecisions): void { + for (const a of layout.annotations ?? []) { + const hex = a?.font?.color; + if (!hex) continue; + const c = parseColor(String(hex)); + if (!c) continue; + const grey = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) < 40; + if (!grey) continue; + const light = (c.r + c.g + c.b) / 765; + a.font = { + ...a.font, + ...(d.font ? { family: d.font } : {}), + color: light < 0.3 ? d.text.primary : light < 0.62 ? d.text.secondary : d.text.muted, + }; + } +} + +/** + * Where to hang the title block so its first line starts 8px down. + * + * `yanchor: 'top'` on a `yref: 'container'` title does not put the block's top + * at `y`. Measured against the real renderer across three font sizes and one + * to four lines, Plotly puts the block top `0.30em` below `y` for a single + * line and `1.00em` below it for two or more — a step, not a slope, and + * independent of the line count after that. The old expression grew with the + * line count instead, so a four-line headline landed 57px too low and ran into + * whatever was under it. + */ +function titleY(layout: any, lines: number, fontSize: number): number { + const height = Number(layout.height) || 300; + const lead = fontSize * (Math.max(1, lines) > 1 ? 1 : 0.3); + return 1 - (8 + lead) / height; +} + +/** What a title block of this many lines actually occupies. Measured: 1.29em. */ +function titleBlockHeight(lines: number, fontSize: number): number { + return Math.max(1, lines) * fontSize * 1.3; +} + +function titleLineCount(title: any): number { + const text = typeof title === 'object' ? title?.text : title; + return Math.max(1, String(text ?? '').split('
').length); +} + +/** Break a line of text to a pixel width, at word boundaries where it can. */ +function wrapToWidth(text: string, width: number, fontSize: number, bold = false): string[] { + const perChar = fontSize * (bold ? 0.63 : 0.55); + const max = Math.max(8, Math.floor(width / perChar)); + if (text.length <= max) return [text]; + const words = text.split(/\s+/); + const lines: string[] = []; + let line = ''; + for (const w of words) { + if (!line) line = w; + else if ((line + ' ' + w).length <= max) line += ' ' + w; + else { + lines.push(line); + line = w; + } + } + if (line) lines.push(line); + return lines; +} + +/** + * Give the title and any legend above the plot room of their own. + * + * Plotly's `automargin` grows the margin *into* the plot, so a chart with a + * two-line headline and a key above it loses that much of its plotting + * rectangle. The chart was already sized to fit; the chrome the theme adds is + * therefore paid for in figure height, not taken out of the plot. + */ +function layoutTopChrome(figure: any, d: DesignDecisions, titleH: number, legendH: number): void { + const layout = figure.layout; + const need = titleH + legendH; + if (need <= 0) return; + const margin = (layout.margin ??= { t: 24, r: 32, b: 56, l: 64 }); + const before = margin.t ?? 0; + margin.t = Math.max(before, need); + const grew = margin.t - before; + if (grew > 0 && Number(layout.height)) layout.height = Math.round(layout.height + grew); + if (layout.title && typeof layout.title === 'object') { + layout.title.y = titleY( + layout, + titleLineCount(layout.title), + Number(layout.title.font?.size) || d.title.headline.fontSize || 17, + ); + } + + // The key sits between the title and the plot, in the room just made. It + // hangs from just under the title rather than standing on the plot: a key + // measured a row short then runs into the plot, which reads, where running + // into the headline does not. + if (legendH > 0 && layout.legend && layout.legend.y > 1) { + const plotH = Math.max(1, (Number(layout.height) || 300) - margin.t - (margin.b ?? 0)); + layout.legend.yanchor = 'top'; + layout.legend.y = 1 + Math.max(8, margin.t - titleH) / plotH; + } + void d; +} + +/** + * A map has its own canvas, land and borders, none of which are axes. + * + * Left alone they stay Plotly's white-and-grey, which puts a white card in the + * middle of a dark house. + */ +function applyGeoSurface(figure: any, d: DesignDecisions): void { + const layout = figure?.layout ?? {}; + const plot = d.surface.plot ?? d.surface.canvas; + for (const key of Object.keys(layout)) { + if (!/^geo\d*$/.test(key)) continue; + const geo = layout[key]; + if (!geo || typeof geo !== 'object') continue; + const land = mixHex(plot, d.text.primary, 0.1); + const border = mixHex(plot, d.text.primary, 0.28); + Object.assign(geo, { + bgcolor: plot, + landcolor: land, + oceancolor: plot, + lakecolor: plot, + subunitcolor: border, + countrycolor: border, + coastlinecolor: border, + framecolor: border, + }); + } +} + +// --------------------------------------------------------------------------- +// Axes +// --------------------------------------------------------------------------- + +function applyAxes(figure: any, d: DesignDecisions, table: any[], say: Say): void { + const layout = figure.layout; + for (const ch of ['x', 'y'] as const) { + const decided = d.axes[ch]; + if (!decided) continue; + for (const key of axisKeys(layout, ch)) { + applyAxis( + layout[key] ?? (layout[key] = {}), + decided, + d, + say, + key, + Math.max(80, (Number(layout.width) || 400) - (layout.margin?.l ?? 0) - (layout.margin?.r ?? 0)), + axisCategories(figure, layout[key], key, ch), + axisSpanDecades(figure, layout[key], key, ch), + ); + } + } + pinSparseTicks(figure, d, say); + thinDenseCategoryTicks(figure, d, say); + applyPolarAxes(figure, d, say); + dedupeBoundaryZeroLines(figure, say); + applyUnits(figure, d, say); + applyTickLabels(figure, d, table, say); +} + +/** Thin dense categorical x labels without removing any bars or categories. */ +function thinDenseCategoryTicks(figure: any, d: DesignDecisions, say: Say): void { + const layout = figure.layout ?? {}; + const plotWidth = Math.max( + 80, + (Number(layout.width) || Number(figure._width) || 400) + - (layout.margin?.l ?? 0) - (layout.margin?.r ?? 0), + ); + const fontSize = d.axes.x?.label.fontSize ?? 11; + for (const key of axisKeys(layout, 'x')) { + const ax = layout[key]; + const categories = Array.isArray(ax?.categoryarray) ? ax.categoryarray : []; + if (ax?.type !== 'category' || categories.length < 2 || ax.tickvals != null) continue; + const domain = Array.isArray(ax.domain) && ax.domain.length === 2 + ? Math.abs(Number(ax.domain[1]) - Number(ax.domain[0])) + : 1; + const band = plotWidth * (Number.isFinite(domain) ? domain : 1); + const chars = Math.min(18, Math.max(3, ...categories.map((c: any) => String(c).length))); + const each = chars * fontSize * 0.58 + 8; + const fits = Math.max(2, Math.floor(band / each)); + if (categories.length <= fits) continue; + const step = (categories.length - 1) / (fits - 1); + const picked = [...new Set(Array.from( + { length: fits }, + (_v, i) => categories[Math.round(i * step)], + ))]; + ax.tickmode = 'array'; + ax.tickvals = picked; + ax.ticktext = picked.map(String); + say('axes.tickCount', `\`${key}\` labels ${picked.length} of ${categories.length} dense categories`); + } +} + +/** + * A positive-only axis puts its zero rule on (or immediately beside) the + * perpendicular domain line. Plotly otherwise draws both strokes a few pixels + * apart because autorange pads zero, producing a doubled scatter baseline. + */ +function dedupeBoundaryZeroLines(figure: any, say: Say): void { + const layout = figure.layout ?? {}; + for (const ch of ['x', 'y'] as const) { + for (const key of axisKeys(layout, ch)) { + const ax = layout[key]; + if (!ax?.zeroline) continue; + const suffix = key.replace(`${ch}axis`, ''); + const perpendicular = layout[`${ch === 'x' ? 'y' : 'x'}axis${suffix}`]; + if (!perpendicular?.showline) continue; + const ref = `${ch}${suffix}`; + const values: number[] = []; + for (const trace of figure.data ?? []) { + if (String(trace?.[`${ch}axis`] ?? ch) !== ref) continue; + for (const value of trace?.[ch] ?? []) { + const n = Number(value); + if (Number.isFinite(n)) values.push(n); + } + } + if (values.length && Math.min(...values) >= 0) { + ax.zeroline = false; + say('axes.zeroRule', `${key} zero coincides with the perpendicular domain — duplicate stroke omitted`); + } + } + } +} + +/** A rank-like run: every integer between the ends is present. */ +function isContiguousIntegers(seen: Set): boolean { + const nums = [...seen].map(Number); + if (nums.some((n) => !Number.isInteger(n))) return false; + return Math.max(...nums) - Math.min(...nums) + 1 === nums.length; +} + +/** + * Name the values the chart actually holds, when there are few of them. + * + * Plotly picks its own round numbers for a continuous axis. On four Olympic + * years that gives "2015, 2020, 2025" — three labels, none of which is a games + * and none of which sits under a data point. Vega-Lite does not do this + * because it is told the domain. Where an axis carries only a handful of + * distinct values, those values *are* the sequence, so they are pinned. + */ +function pinSparseTicks(figure: any, d: DesignDecisions, say: Say): void { + const layout = figure.layout; + const MAX = 8; + for (const ch of ['x', 'y'] as const) { + const a = d.axes[ch]; + if (!a) continue; + // A measure axis normally wants round numbers — naming the seven values + // a short series happens to hold ("1, 16, 29, 43, 51, 67") reads as + // noise. The exception is a rank-like axis, whose values are a + // contiguous run of integers and *are* the scale. + const measure = d.bound.measureChannels.includes(ch); + for (const key of axisKeys(layout, ch)) { + const ax = layout[key]; + if (!ax || ax.type === 'category' || ax.type === 'log') continue; + if (ax.tickvals != null || !ax.showticklabels) continue; + const want = key.replace('axis', ''); + const seen = new Set(); + for (const trace of figure.data ?? []) { + if (CHROME_TRACES.has(String(trace?.type))) continue; + if (String(trace?.[`${ch}axis`] ?? ch) !== want) continue; + for (const v of trace?.[ch] ?? []) if (v != null) seen.add(v); + } + if (seen.size < 2 || seen.size > MAX) continue; + if (measure && !isContiguousIntegers(seen)) continue; + const sorted = [...seen].sort((p, q) => ( + ax.type === 'date' + ? new Date(p).getTime() - new Date(q).getTime() + : Number(p) - Number(q) + )); + const picked = fitTicksToBand(figure, ax, sorted, a.label.fontSize ?? 11); + if (!picked.length) continue; + if (ax.type === 'date' && !ax.tickformat) { + ax.tickformat = dateFormatFor(picked); + ax.tickangle = 0; + } + ax.tickmode = 'array'; + ax.tickvals = picked; + delete ax.nticks; + say('axes.tickCount', `\`${key}\` names the ${picked.length} value(s) it holds, not Plotly's round numbers`); + } + } +} + +/** + * A polar plot keeps its scales inside `layout.polar`, out of reach of the + * cartesian pass. Its radial labels sit in the middle of the plot, where + * Plotly turns them on their side as soon as the circle gets small, so they + * are held straight and their count is kept low. + */ +function applyPolarAxes(figure: any, d: DesignDecisions, say: Say): void { + const layout = figure.layout; + let saidRose = false; + const polarKeys = new Set( + Object.keys(layout ?? {}).filter(key => /^polar\d*$/.test(key)), + ); + // Plotly implicitly creates the default `polar` subplot when a trace does + // not declare one. Materialize it so themed furniture also reaches polar + // traces that passed through a generic figure combiner. + if ((figure.data ?? []).some((trace: any) => + (trace?.type === 'barpolar' || trace?.type === 'scatterpolar') + && String(trace?.subplot ?? 'polar') === 'polar')) { + polarKeys.add('polar'); + layout.polar ??= {}; + } + for (const key of polarKeys) { + if (!/^polar\d*$/.test(key)) continue; + const polar = layout[key]; + if (!polar || typeof polar !== 'object') continue; + const plot = d.surface.plot ?? d.surface.canvas; + const subplot = key === 'polar' ? 'polar' : key; + const subplotTraces = (figure.data ?? []).filter((trace: any) => + String(trace?.subplot ?? 'polar') === subplot); + const radar = subplotTraces.some((trace: any) => + trace?.type === 'scatterpolar' + && trace?.fill === 'toself'); + const rose = subplotTraces.some((trace: any) => trace?.type === 'barpolar'); + polar.bgcolor = plot; + for (const [name, a] of [['radialaxis', d.axes.y], ['angularaxis', d.axes.x]] as const) { + if (!a) continue; + const ax = polar[name] ?? (polar[name] = {}); + if (rose) { + // A rose has no empty axis gutter: the radial ray, its ticks, + // and its numbers would be drawn through the wedges. Replace + // that cartesian furniture with quiet rings below the marks. + // Month labels already locate the angular bands, so spokes and + // little rim ticks only repeat boundaries the wedges provide. + ax.ticks = ''; + ax.ticklen = 0; + ax.showline = false; + if (name === 'radialaxis') { + ax.showgrid = true; + ax.gridcolor = mixHex(plot, d.text.secondary, 0.28); + ax.gridwidth = 0.8; + ax.layer = 'below traces'; + ax.showticklabels = false; + ax.nticks = 4; + } else { + ax.showgrid = false; + } + if (!saidRose) { + say( + 'structure.axis.polar', + 'the rose uses quiet concentric guides and fitted values at wedge tips instead of an axis ray through the data', + ); + saidRose = true; + } + } else if (radar) { + // Rings and spokes are the radar's coordinate system, not + // optional cartesian reference lines. Keep them present but + // quiet so they locate a vertex without competing with it. + const strength = name === 'radialaxis' ? 0.18 : 0.24; + ax.showgrid = true; + ax.gridcolor = mixHex(plot, a.grid.color ?? d.text.secondary, strength); + ax.gridwidth = Math.min(1, a.grid.width ?? 1); + ax.showline = true; + ax.linecolor = mixHex(plot, a.domain.color ?? d.text.secondary, 0.42); + ax.linewidth = Math.min(1, a.domain.width ?? 1); + } else { + ax.showgrid = a.grid.show; + if (a.grid.show) { + ax.gridcolor = a.grid.color; + ax.gridwidth = a.grid.width; + } + ax.showline = a.domain.show; + if (a.domain.show) { + ax.linecolor = a.domain.color; + ax.linewidth = a.domain.width; + } + } + if (a.label.show !== false) { + ax.tickfont = { ...(ax.tickfont ?? {}), ...fontOf(a.label, d.font) }; + if (name === 'radialaxis') { + ax.tickangle = 0; + if (!rose && ax.tickmode !== 'array') ax.nticks = Math.min(a.tickCount ?? 4, 4); + } + } + } + if (rose) addRoseValueLabels(figure, key, subplotTraces, d); + } +} + +/** Put a total near each rose tip, omitting labels whose wedge cannot hold it. */ +function addRoseValueLabels( + figure: any, + subplot: string, + traces: any[], + d: DesignDecisions, +): void { + figure.data = (figure.data ?? []).filter((trace: any) => + !(trace?._themeRole === 'rose-value-labels' && String(trace?.subplot ?? 'polar') === subplot)); + + const totals = new Map(); + for (const trace of traces) { + if (trace?.type !== 'barpolar') continue; + for (let i = 0; i < Math.min(trace.theta?.length ?? 0, trace.r?.length ?? 0); i++) { + const value = Number(trace.r[i]); + if (!Number.isFinite(value) || value <= 0) continue; + const key = String(trace.theta[i]); + const current = totals.get(key); + if (current) current.value += value; + else totals.set(key, { theta: trace.theta[i], value }); + } + } + if (!totals.size) return; + + const layout = figure.layout ?? {}; + const margin = layout.margin ?? {}; + const plotW = (Number(layout.width) || Number(figure._width) || 400) + - (margin.l ?? 0) - (margin.r ?? 0); + const plotH = (Number(layout.height) || Number(figure._height) || 320) + - (margin.t ?? 0) - (margin.b ?? 0); + const radius = Math.max(40, Math.min(plotW, plotH) / 2); + const fontSize = d.axes.y?.label.fontSize ?? 11; + const max = Math.max(...[...totals.values()].map(item => item.value)); + const count = totals.size; + const tipPad = (max * fontSize * 0.7) / radius; + const theta: any[] = []; + const r: number[] = []; + const text: string[] = []; + + for (const item of totals.values()) { + const label = Number.isInteger(item.value) + ? String(item.value) + : String(Number(item.value.toPrecision(3))); + const radialPx = (item.value / max) * radius; + const arcPx = (2 * Math.PI * radialPx / count) * 0.65; + const labelPx = label.length * fontSize * 0.58; + if (radialPx < fontSize * 1.6 || arcPx < labelPx) continue; + theta.push(item.theta); + r.push(item.value + tipPad); + text.push(label); + } + if (!text.length) return; + + const radialAxis = figure.layout?.[subplot]?.radialaxis + ?? (figure.layout[subplot].radialaxis = {}); + const currentMin = Array.isArray(radialAxis.range) ? Number(radialAxis.range[0]) : 0; + const currentMax = Array.isArray(radialAxis.range) ? Number(radialAxis.range[1]) : 0; + radialAxis.range = [ + Number.isFinite(currentMin) ? currentMin : 0, + Math.max(Number.isFinite(currentMax) ? currentMax : 0, max + tipPad * 2.5), + ]; + delete radialAxis.rangemode; + + figure.data.push({ + type: 'scatterpolar', + subplot, + mode: 'text', + theta, + r, + text, + textposition: 'middle center', + textfont: { + ...fontOf(d.axes.y?.label ?? d.dataLabels.text, d.font), + color: d.text.primary, + }, + hoverinfo: 'skip', + showlegend: false, + _role: 'context', + _themeRole: 'rose-value-labels', + }); +} + +/** + * The names a banded axis carries: what the layout declares, or what the + * traces bound to that axis actually plot. + */ +function axisCategories(figure: any, ax: any, key: string, ch: 'x' | 'y'): any[] { + if (Array.isArray(ax?.categoryarray) && ax.categoryarray.length) return ax.categoryarray; + if (ax?.type !== 'category') return []; + const short = key.replace('axis', ''); + const seen = new Set(); + for (const t of figure.data ?? []) { + if (String(t?.[`${ch}axis`] ?? ch) !== short) continue; + for (const v of t?.[ch] ?? []) seen.add(String(v)); + } + return [...seen]; +} + +/** + * Would this axis's names read straight in the width it actually has? + * + * Only answerable for a banded axis, where the band width is the width divided + * by the number of names. Anything else is left to the house. + */ +function labelsFitStraight(ax: any, key: string, a: ResolvedAxis, width: number, cats: any[]): boolean { + if (!key.startsWith('x')) return true; + if ((a.label.angle ?? 0) !== 0) return true; + if (!Array.isArray(cats) || !cats.length) return true; + const span = Array.isArray(ax.domain) && ax.domain.length === 2 + ? Math.abs(Number(ax.domain[1]) - Number(ax.domain[0])) + : 1; + const band = (width * (Number.isFinite(span) ? span : 1)) / cats.length; + const longest = Math.max(...cats.map((c: any) => String(c).length)); + // Names need air between them, or 'JanFebMar' reads as one word. + return longest * (a.label.fontSize ?? 11) * 0.58 + 5 <= band; +} + +/** + * How many powers of ten the values bound to this axis cover. Only meaningful + * on a log axis, and only used to decide whether its minors need naming. + */ +function axisSpanDecades(figure: any, ax: any, key: string, ch: 'x' | 'y'): number { + if (Array.isArray(ax?.range) && ax.range.length === 2) { + return Math.abs(Number(ax.range[1]) - Number(ax.range[0])); + } + const want = key.replace('axis', ''); + const values: number[] = []; + for (const trace of figure?.data ?? []) { + if (String(trace?.[`${ch}axis`] ?? ch) !== want) continue; + for (const v of trace?.[ch] ?? []) { + const n = Number(v); + if (Number.isFinite(n) && n > 0) values.push(n); + } + } + if (values.length < 2) return 0; + return Math.log10(Math.max(...values)) - Math.log10(Math.min(...values)); +} + +function applyAxis( + ax: any, + a: ResolvedAxis, + d: DesignDecisions, + say: Say, + key: string, + width: number, + cats: any[], + decades: number, +): void { + // Grid + ax.showgrid = a.grid.show; + if (a.grid.show) { + ax.gridcolor = a.grid.color; + ax.gridwidth = a.grid.width; + const dash = dashOf(a.grid.dash); + if (dash) ax.griddash = dash; + } + + // Zero — Plotly draws one by default, which is a decision the house owns. + if (a.zeroRule?.show) { + ax.zeroline = true; + ax.zerolinecolor = a.zeroRule.color; + ax.zerolinewidth = a.zeroRule.width; + } else { + ax.zeroline = false; + } + + // Domain line + ax.showline = a.domain.show; + if (a.domain.show) { + ax.linecolor = a.domain.color; + ax.linewidth = a.domain.width; + } + + // Ticks + if (a.ticks.show) { + ax.ticks = 'outside'; + ax.ticklen = a.ticks.size; + ax.tickcolor = a.ticks.color; + ax.tickwidth = a.ticks.width; + } else { + ax.ticks = ''; + ax.ticklen = 0; + } + + // Tick labels. A facet grid hides the labels on every panel but the + // outermost, which is a fact about the layout, not a house preference — + // turning them back on prints the same scale four times. + ax.showticklabels = a.label.show !== false && ax.showticklabels !== false; + if (ax.showticklabels) { + ax.tickfont = { ...(ax.tickfont ?? {}), ...fontOf(a.label, d.font) }; + // A straight label angle was decided against the whole axis. A facet + // panel holds a fraction of it, and names that read straight across a + // full width run into each other across a quarter of one — so the + // angle the template chose stands where the house's will not fit. + if (a.label.angle != null) { + if (labelsFitStraight(ax, key, a, width, cats)) ax.tickangle = a.label.angle; + else { + say( + 'axes.label.angle', + `\`${key}\` keeps its turned labels — straight they would not fit the panel`, + ); + } + } + if (a.label.padding != null) ax.ticklabelstandoff = a.label.padding; + } + + // Title + const titleText = typeof ax.title === 'string' ? ax.title : ax.title?.text; + if (!a.title.show) { + ax.title = { text: '' }; + } else if (titleText) { + const text = a.title.unit && !titleText.includes(a.title.unit) + ? `${titleText} (${a.title.unit})` + : titleText; + ax.title = { + text: styleText(text, a.title), + font: fontOf(a.title, d.font), + standoff: (ax.title?.standoff ?? 16), + }; + if (a.title.placement === 'flatAboveAxis' && key.startsWith('y')) { + // A y title set flat above the axis is an annotation in Plotly, not + // a title property. Approximated as a rotated title for now. + say('annotation.axisTitlePlacement', 'flat axis title not realized on a y axis — kept rotated'); + } + } + + // A tick budget is stated for a whole axis. A facet panel holds a fraction + // of the width, so it gets that fraction of the budget — otherwise the + // last tick of one panel prints on top of the first tick of the next. + // A log axis is ticked by decade. Asking it for a number of ticks makes + // Plotly name every minor as well — 2, 3, 4 … 9 between each power. + if (a.tickCount != null && ax.type !== 'category' && ax.type !== 'log') { + const dom = Array.isArray(ax.domain) && ax.domain.length === 2 + ? Math.abs(Number(ax.domain[1]) - Number(ax.domain[0])) + : 1; + ax.nticks = Math.max(2, Math.round(a.tickCount * (Number.isFinite(dom) ? dom : 1))); + } + + // Plotly names the minors of a log axis on its own once the range is short + // enough — "2, 5, 10k, 2, 5, 100k" reads as a broken sequence, because the + // numbers repeat and nothing on the axis says they are decades apart. + // Naming the powers alone is what every reference chart does. + if (ax.type === 'log' && ax.dtick == null) { + if (decades >= 1.5) ax.dtick = 1; + } + + // A budget in *numbers* is not a budget in *room*. Sixteen panels across + // one page leave 50px each, which holds one year, not four — and Plotly + // will print all four on top of each other rather than drop any. + if (key.startsWith('x') && ax.type !== 'category' && ax.type !== 'log' && ax.showticklabels) { + const dom = Array.isArray(ax.domain) && ax.domain.length === 2 + ? Math.abs(Number(ax.domain[1]) - Number(ax.domain[0])) + : 1; + const span = Number.isFinite(dom) ? dom : 1; + const each = tickLabelChars(ax) * (a.label.fontSize ?? 11) * 0.58 + 8; + const band = width * span - (span < 0.99 ? each * 1.5 : 0); + const fits = Math.max(1, Math.floor(band / each)); + if (fits < (ax.nticks ?? 6)) { + ax.nticks = Math.max(2, fits); + say('axes.tickCount', `\`${key}\` holds ${fits} label(s) in the room it has, not ${a.tickCount}`); + } + } +} + +/** + * Keep only as many of these ticks as the panel has room to print. Plotly + * prints every value in `tickvals`, however narrow the panel, so a grid of + * sixteen facets writes four years on top of each other. + */ +function fitTicksToBand(figure: any, ax: any, picked: any[], fontSize: number): any[] { + const layout = figure.layout ?? {}; + const width = Math.max(80, (Number(layout.width) || 400) - (layout.margin?.l ?? 0) - (layout.margin?.r ?? 0)); + const dom = Array.isArray(ax.domain) && ax.domain.length === 2 + ? Math.abs(Number(ax.domain[1]) - Number(ax.domain[0])) + : 1; + // A panel's end labels hang over its edges, so a facet has to leave a + // label's width of air or its last tick lands on its neighbour's first. + const span = Number.isFinite(dom) ? dom : 1; + const each = tickLabelChars(ax) * fontSize * 0.58 + 8; + const band = width * span - (span < 0.99 ? each * 1.5 : 0); + const fits = Math.max(1, Math.floor(band / each)); + // Panels sit edge to edge, so a tick at the right edge of one prints on + // the tick at the left edge of the next. Every panel but the last gives + // up its final label. + const trimmed = span < 0.99 && Number(ax.domain?.[1]) < 0.99 && picked.length > 1 + ? picked.slice(0, -1) + : picked; + picked = trimmed; + if (fits < 2) return [picked[0]]; + if (picked.length > fits) { + // Keep the ends and spread the rest, so the range still reads. + const step = (picked.length - 1) / (fits - 1); + const out: any[] = []; + for (let i = 0; i < fits; i++) out.push(picked[Math.round(i * step)]); + picked = [...new Set(out)]; + } + return spaceOutTicks(picked, each, band, ax); +} + +/** + * Drop ticks that land on top of one another. + * + * Thinning by index assumes the values are evenly spaced, and observed values + * are not — a series sampled at 1995, 2000 … 2015, 2018, 2020, 2023 keeps + * every fourth entry and still prints "2018" against "2020". So the survivors + * are walked in *position* and any that falls within a label's width of the + * one before it is dropped. The last tick is kept in preference to its + * neighbour: it carries the end of the range. + */ +function spaceOutTicks(picked: any[], each: number, band: number, ax: any): any[] { + if (picked.length < 3) return picked; + const at = (v: any) => (ax.type === 'date' ? new Date(v).getTime() : Number(v)); + const nums = picked.map(at); + if (nums.some((n) => !Number.isFinite(n))) return picked; + const lo = Math.min(...nums); + const hi = Math.max(...nums); + if (!(hi > lo)) return picked; + const px = (v: number) => ((v - lo) / (hi - lo)) * band; + const kept: any[] = [picked[0]]; + let lastPx = px(nums[0]); + for (let i = 1; i < picked.length - 1; i++) { + if (px(nums[i]) - lastPx < each) continue; + kept.push(picked[i]); + lastPx = px(nums[i]); + } + const endPx = px(nums[nums.length - 1]); + while (kept.length > 1 && endPx - px(at(kept[kept.length - 1])) < each) kept.pop(); + kept.push(picked[picked.length - 1]); + return kept; +} + +/** How wide a tick label on this axis reads, in characters. */ +function tickLabelChars(ax: any): number { + const fmt = typeof ax.tickformat === 'string' ? ax.tickformat : ''; + if (fmt.includes('%')) { + if (/%d/.test(fmt) || /%b/.test(fmt)) return fmt.replace(/%./g, 'xxx').length; + return 4; + } + if (Array.isArray(ax.ticktext) && ax.ticktext.length) { + return Math.max(...ax.ticktext.map((t: any) => String(t).length)); + } + return ax.type === 'date' ? 4 : 5; +} + +/** + * The unit the measure carries, written onto the ticks. + * + * Plotly states where a suffix appears with `showticksuffix` — all, first or + * last — which is the same vocabulary the house uses. Only `firstAndLast` has + * no counterpart, and it is approximated with every tick. + */ +function applyUnits(figure: any, d: DesignDecisions, say: Say): void { + const layout = figure.layout; + // An axis showing a normalized stack plots a share, not the measure, so the + // measure's unit would be a lie on it. The template has already written the + // right suffix there. + const normalized = new Set(); + for (const t of figure.data ?? []) { + if (t?.groupnorm === 'percent') normalized.add((t.yaxis ?? 'y').replace('y', 'yaxis')); + } + for (const ch of ['x', 'y'] as const) { + const unit = d.axes[ch]?.unit; + if (!unit || unit.where === 'never' || !unit.text) continue; + const prefix = /^[$£€¥]$/.test(unit.text); + const where = unit.where === 'firstTick' ? 'first' + : unit.where === 'lastTick' ? 'last' + : 'all'; + for (const key of axisKeys(layout, ch)) { + const ax = layout[key]; + if (ax.type === 'category') continue; + if (normalized.has(key)) { + say('axes.unit', `${key} shows a share of the whole — the measure's unit left off`); + continue; + } + if (prefix) { + ax.tickprefix = unit.text; + ax.showtickprefix = where; + } else { + // A word set flush against the number reads as one token — + // "400ppm". A symbol does not: "50%" is right and "50 %" is + // not. So the space goes in for words and stays out for signs. + ax.ticksuffix = /^[A-Za-z]/.test(unit.text) ? ` ${unit.text}` : unit.text; + ax.showticksuffix = where; + } + } + if (unit.where === 'firstAndLast') { + say('annotation.unit', 'unit written on every tick — Plotly states first, last or all, not both ends'); + } + } +} + +/** Which ticks carry a label: the values the data holds, thinned or cut to the ends. */ +function applyTickLabels(figure: any, d: DesignDecisions, table: any[], say: Say): void { + const layout = figure.layout; + for (const ch of ['x', 'y'] as const) { + const a = d.axes[ch]; + if (!a?.tickLabels || a.tickLabels === 'all') continue; + const field = ch === 'x' ? d.bound.categoryField : undefined; + for (const key of axisKeys(layout, ch)) { + const ax = layout[key]; + // A category axis already labels exactly what it holds, and Plotly + // reads `tickvals` there as *positions*, not as names — stating a + // year on one collapses the whole scale onto the first band. + if (ax.type === 'category') continue; + const values = observedValues(ax, table, field); + if (!values.length) continue; + let picked = thin(values, a.tickLabels, key); + if (!picked?.length) continue; + if (ax.type === 'date' && !ax.tickformat) { + ax.tickformat = dateFormatFor(picked); + ax.tickangle = 0; + } + if (ch === 'x') picked = fitTicksToBand(figure, ax, picked, a.label.fontSize ?? 11); + ax.tickmode = 'array'; + ax.tickvals = picked; + say('structure.axis.tickLabels', `${key} ticked at ${picked.length} observed values (${a.tickLabels})`); + } + } +} + +/** The span the ticks cover decides how much of a date needs writing. */ +function dateFormatFor(values: any[]): string { + const times = values.map((v) => new Date(v).getTime()).filter((n) => Number.isFinite(n)); + if (times.length < 2) return '%Y'; + const days = (Math.max(...times) - Math.min(...times)) / 86_400_000; + // What matters is not the span but the *step*: eight ticks across three + // years written as years print "2020 2020 2021 2021" — the same label + // twice, which reads as a mistake. + const step = days / (times.length - 1); + if (days > 900 && step > 300) return '%Y'; + if (days > 60 && step > 20) return '%b %Y'; + return '%d %b'; +} + +/** The values this axis actually holds, in order. */ +function observedValues(ax: any, table: any[], field: string | undefined): any[] { + if (Array.isArray(ax?.categoryarray)) return ax.categoryarray; + if (Array.isArray(ax?.tickvals)) return ax.tickvals; + if (field) { + const seen = new Set(); + for (const row of table) if (row?.[field] != null) seen.add(row[field]); + return [...seen]; + } + return []; +} + +/** + * Thin a run of observed values to what the axis can label. + * + * The last value is always kept — it is the reading the chart ends on — and + * the one before it is dropped where the two would collide. + */ +function thin(values: any[], mode: string, key: string): any[] | null { + if (values.length < 2) return values; + if (mode === 'endpoints') return [values[0], values[values.length - 1]]; + if (mode === 'observed') return values; + if (mode !== 'sparse') return null; + + const span = key.startsWith('x') ? 6 : 5; + const step = Math.max(1, Math.ceil(values.length / span)); + const picked = values.filter((_, i) => i % step === 0); + const last = values[values.length - 1]; + if (picked[picked.length - 1] !== last) { + // Two ticks a fraction of a step apart print on top of each other. + const lastIndex = values.indexOf(picked[picked.length - 1]); + // Two ticks less than a step apart print on top of each other; the + // last value is the one that has to be named, so the other goes. + if (values.length - 1 - lastIndex < step) picked.pop(); + picked.push(last); + } + return picked; +} + +// --------------------------------------------------------------------------- +// Marks +// --------------------------------------------------------------------------- + +/** VL states a point's *area* in px²; Plotly states its diameter in px. */ +function diameterOf(area: number): number { + return Math.max(2, Math.round(2 * Math.sqrt(area / Math.PI))); +} + +/** Keep Plotly's default in step with the Vega-Lite countability heuristic. */ +const MAX_DOTTED_READINGS = 12; + +/** + * How wide a dot on a line may be, or 0 when it should not be drawn. + * + * A dot on a line is not a mark in its own right — it is a reading aid that + * says "a value was measured here". Two things bound it. It must not outweigh + * the line it sits on, so it is capped against the stroke; and it must not + * touch its neighbours, so it is capped against the gap between points. Where + * the points are packed tighter than a dot is wide, the aid has become a + * smear and is better left off. + */ +function dotDiameter(trace: any, figure: any, wanted: number, strokeWidth: number): number { + const xValues: any[] = Array.isArray(trace.x) ? trace.x : []; + const layout = figure.layout ?? {}; + const xRef = String(trace.xaxis ?? 'x'); + const axis = layout[xRef === 'x' ? 'xaxis' : `xaxis${xRef.slice(1)}`] ?? layout.xaxis ?? {}; + const renderableX = (value: any): boolean => { + if (value == null) return false; + if (axis.type === 'category') return true; + return Number.isFinite(axis.type === 'date' ? Date.parse(String(value)) : Number(value)); + }; + const observedIndices = xValues + .map((_value, index) => index) + .filter(index => + renderableX(xValues[index]) + && (!Array.isArray(trace.y) || trace.y[index] != null)); + if (observedIndices.length < 2) return wanted; + if (observedIndices.length > MAX_DOTTED_READINGS) return 0; + const figurePlotW = Math.max( + 40, + (Number(layout.width) || 400) - (layout.margin?.l ?? 0) - (layout.margin?.r ?? 0), + ); + const domain = Array.isArray(axis.domain) ? axis.domain : [0, 1]; + const rawDomainSpan = Math.abs(Number(domain[1]) - Number(domain[0])); + const plotW = figurePlotW * (Number.isFinite(rawDomainSpan) && rawDomainSpan > 0 ? rawDomainSpan : 1); + + let relativeGap: number; + if (axis.type === 'category') { + const gaps = observedIndices.slice(1).map((index, i) => index - observedIndices[i]); + relativeGap = Math.min(...gaps) / Math.max(1, xValues.length - 1); + } else { + const positions = observedIndices.map(index => { + const value = xValues[index]; + return axis.type === 'date' ? Date.parse(String(value)) : Number(value); + }); + if (positions.every(Number.isFinite)) { + positions.sort((a, b) => a - b); + const span = positions[positions.length - 1] - positions[0]; + const gaps = positions.slice(1).map((value, i) => value - positions[i]); + relativeGap = span > 0 ? Math.min(...gaps) / span : 0; + } else { + relativeGap = 1 / (observedIndices.length - 1); + } + } + + const spacing = plotW * relativeGap; + if (spacing < 6) return 0; + return Math.max(2, Math.round(Math.min(wanted, strokeWidth * 3.5, spacing * 0.55))); +} + +function applyMarks(figure: any, d: DesignDecisions, table: any[], say: Say): void { + const layout = figure.layout; + const m = d.marks; + + // Band occupancy is one number for the whole figure: Plotly sizes bars by + // the gap left between them, not by a mark width. + // A trace another trace fills down to is the floor of a band, not a line + // of its own. + const traces: any[] = figure.data ?? []; + const floors = new Set(); + traces.forEach((t, i) => { if (t?.fill === 'tonexty' && i > 0) floors.add(i - 1); }); + + const bars = traces.filter((t: any) => markFamilies(t).includes('bar')); + if (bars.length) { + layout.bargap = Math.max(0, Math.min(0.9, 1 - m.bandFraction)); + if (layout.barmode === 'group' && bars.length > 1) layout.bargroupgap = 0.05; + } + + for (const [index, trace] of traces.entries()) { + if (CHROME_TRACES.has(String(trace?.type))) continue; + // Furniture keeps the weight `applyFurnitureTraces` gave it. + if (isContextTrace(trace) || isReferenceTrace(trace)) continue; + if (isLegendProxy(trace)) continue; + let fams = markFamilies(trace); + const filled = fams.includes('area') + || (trace.fill != null && trace.fill !== 'none') + || floors.has(index); + let preventThemePoints = figure._suppressLinePoints === true; + + if (fams.includes('line') && fams.includes('point') && !filled + && (preventThemePoints || figure._themeDefaultLinePoints === true)) { + const secondaryArea = m.point?.secondarySize ?? Math.min(m.point?.size ?? 40, 25); + const dot = dotDiameter(trace, figure, diameterOf(secondaryArea), m.strokeWidth ?? 2); + if (preventThemePoints || dot === 0) { + trace.mode = String(trace.mode ?? 'lines') + .split('+') + .filter((part: string) => part !== 'markers') + .join('+') || 'lines'; + fams = markFamilies(trace); + preventThemePoints = true; + if (dot === 0) { + say('marks.point.show', 'theme-default dots left off — the line is too dense'); + } + } else { + trace.marker = { ...(trace.marker ?? {}), size: dot }; + } + } + + if (fams.includes('bar') || fams.includes('arc')) { + if (m.outline && !isContextTrace(trace)) { + trace.marker = { + ...(trace.marker ?? {}), + line: { color: m.outline.color, width: m.outline.width }, + }; + } + if (m.cornerRadius != null && trace.type === 'bar' && trace.marker?.cornerradius == null) { + trace.marker = { ...(trace.marker ?? {}), cornerradius: m.cornerRadius }; + } + } + + if (fams.includes('arc') && m.slice) { + // A wedge gap is drawn as a stroke in the surface colour: Plotly has + // no gap between slices. + trace.marker = { + ...(trace.marker ?? {}), + line: { color: m.slice.color || d.surface.canvas, width: m.slice.gap }, + }; + } + + if (fams.includes('rect') && m.tile) { + trace.xgap = m.tile.gap; + trace.ygap = m.tile.gap; + } + + if (fams.includes('line')) { + const areaEdge = trace.type === 'scatter' && fams.includes('area'); + const radarEdge = trace.type === 'scatterpolar' && fams.includes('area'); + const width = areaEdge + ? Math.min(1, m.strokeWidth) + : radarEdge ? Math.min(2.5, m.strokeWidth) : m.strokeWidth; + trace.line = { ...(trace.line ?? {}), width }; + if (m.interpolate && !trace._preserveLineShape + && (!trace.line.shape || trace.line.shape === 'linear')) { + const shape = plotlyLineShape(m.interpolate); + if (shape) trace.line.shape = shape; + else say('marks.line.interpolate', `\`${m.interpolate}\` has no Plotly line shape — left straight`); + } + } + + if (fams.includes('area') && m.fillOpacity != null && trace.fillcolor == null) { + trace.opacity = m.fillOpacity; + } + + if (fams.includes('point')) { + const secondary = trace?._markerRole === 'secondary'; + const size = secondary ? m.point?.secondarySize : m.point?.size; + if (size != null && (secondary || trace.marker?.size == null)) { + trace.marker = { ...(trace.marker ?? {}), size: diameterOf(size) }; + } + if (m.point?.haloColor && m.point.haloWidth) { + trace.marker = { + ...(trace.marker ?? {}), + line: { color: m.point.haloColor, width: m.point.haloWidth }, + }; + } + } + + if (fams.includes('boxplot') && m.summary) { + if (m.summary.fill === false) trace.fillcolor = 'rgba(0,0,0,0)'; + if (m.summary.widthFraction != null) trace.width = undefined; + } + + // A house that dots its lines says so through the chart's own options + // where the template offers one; where it does not, the dots are added + // here. + // A filled band has no line to dot — its edge is the top of an area, + // and dotting it reads as data points that are not there. + if (m.point?.show && fams.includes('line') && !fams.includes('point') + && !filled && !preventThemePoints) { + const secondaryArea = m.point.secondarySize ?? Math.min(m.point.size ?? 40, 25); + const dot = dotDiameter(trace, figure, diameterOf(secondaryArea), m.strokeWidth ?? 2); + if (dot > 0) { + trace.mode = String(trace.mode ?? 'lines').includes('markers') + ? trace.mode + : `${trace.mode ?? 'lines'}+markers`; + trace.marker = { ...(trace.marker ?? {}), size: dot }; + } else { + say('marks.point.show', 'dots left off — the points sit closer together than a dot is wide'); + } + } + } + + // A sized mark's range. `marker.size` has already been mapped to pixel + // diameters by the template, so the house's range is imposed by rescaling + // those diameters — going through `sizeref` instead would treat drawn + // pixels as data and flatten the differences under its square root. + if (m.sizeRange) { + const [lo, hi] = m.sizeRange; + for (const trace of figure.data ?? []) { + const sizes = trace?.marker?.size; + if (!Array.isArray(sizes)) continue; + const areas = sizes.map((dm: any) => Math.PI * (Number(dm) / 2) ** 2); + const finite = areas.filter((a: number) => Number.isFinite(a) && a > 0); + if (!finite.length) continue; + const min = Math.min(...finite); + const max = Math.max(...finite); + const floor = m.minSize != null ? Math.max(lo, m.minSize) : lo; + // A bubble the house has scaled down to two pixels is a speck: it + // is neither a readable value nor visibly a mark. Four pixels is + // the floor at which a dot still reads as one. + const MIN_DIAMETER = 4; + trace.marker.size = areas.map((a: number) => { + if (!Number.isFinite(a)) return Math.max(MIN_DIAMETER, diameterOf(floor)); + const t = max > min ? (a - min) / (max - min) : 1; + return Math.max(MIN_DIAMETER, diameterOf(floor + t * (hi - floor))); + }); + } + } + + void table; +} + +function plotlyLineShape(interpolate: string): string | undefined { + switch (interpolate) { + case 'monotone': + case 'basis': + case 'cardinal': + case 'catmull-rom': + case 'natural': + return 'spline'; + case 'step': + case 'step-after': + return 'hv'; + case 'step-before': + return 'vh'; + case 'linear': + return 'linear'; + default: + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Series ink +// --------------------------------------------------------------------------- + +/** + * Repaint what the template painted. + * + * The template chose its colours from Flint's own palette against a white page. + * Everything the house has an opinion about is re-stated here; a trace that + * carries per-point colours (a status waterfall, a colour-mapped scatter) is + * re-stated value by value. + */ +/** + * Paint the direction blocks of a waterfall, candlestick or OHLC trace. + * + * Where the house names no status inks the template's colours stand: an + * arbitrary indexed ink on "down" would say the wrong thing. + */ +function paintDirectional(trace: any, d: DesignDecisions, say: Say): void { + const status = d.series.status; + let positive = status?.positive; + let negative = status?.negative; + let neutral = status?.neutral; + + if (!positive && !negative && !neutral) { + // A candlestick's red and green are a convention its readers rely on, + // so without status inks it is left alone. A waterfall's up and down + // are only contributions, and can take the indexed set. + if (trace.type !== 'waterfall') { + say('ink.series', 'rise/fall colours kept — the house names no status inks'); + return; + } + const inks = d.series.categorical ?? []; + if (!inks.length) return; + positive = inks[0]; + negative = inks[1] ?? inks[0]; + neutral = mixHex(d.text.primary, d.surface.plot ?? d.surface.canvas, 0.45); + say('ink.series', 'no status inks — the waterfall takes the indexed set, totals a neutral'); + } + + const blocks: Array<[string, string | undefined]> = [ + ['increasing', positive], + ['decreasing', negative ?? positive], + ['totals', neutral ?? positive], + ]; + for (const [key, ink] of blocks) { + const block = trace[key]; + if (!block || !ink) continue; + if (block.marker) block.marker = { ...block.marker, color: ink }; + else if (block.line) block.line = { ...block.line, color: ink }; + else trace[key] = { ...block, marker: { color: ink } }; + if (block.fillcolor != null) block.fillcolor = ink; + } + say('ink.series', 'rise, fall and total take the house status inks'); +} + +function applySeriesInk(figure: any, d: DesignDecisions, table: any[], say: Say): void { + const s = d.series; + const traces = dataTraces(figure); + if (!traces.length) return; + + // A pie is one Plotly trace with one label/value pair per slice. When the + // house has an overflow ink, match Vega-Lite's part-to-whole policy: the + // largest K shares keep the indexed colours and the remaining shares sum + // into one explicit Others slice. Cycling the palette would make unrelated + // wedges look identical while still pretending they were distinguishable. + const foldedPies = new Set(); + if (s.overflowTail && s.overflow && s.categorical.length) { + for (const trace of traces) { + if (trace?.type !== 'pie' || !Array.isArray(trace.labels) || !Array.isArray(trace.values)) continue; + const count = Math.min(trace.labels.length, trace.values.length); + const keep = s.categorical.length; + if (count <= keep) continue; + const ranked = Array.from({ length: count }, (_, i) => ({ + i, + label: trace.labels[i], + value: Number(trace.values[i]) || 0, + })).sort((a, b) => (Math.abs(b.value) - Math.abs(a.value)) || (a.i - b.i)); + const head = ranked.slice(0, keep); + const tail = ranked.slice(keep); + const othersLabel = `Others (${tail.length})`; + trace.labels = [...head.map((d) => d.label), othersLabel]; + trace.values = [...head.map((d) => d.value), tail.reduce((sum, d) => sum + d.value, 0)]; + trace.marker = { + ...(trace.marker ?? {}), + colors: [...s.categorical.slice(0, keep), s.overflow], + }; + foldedPies.add(trace); + say( + 'ink.series.categorical', + `${count} series past ${keep} inks — the ${keep} largest keep a colour, the rest sum into one "${othersLabel}" slice`, + ); + } + } + + // Unlike pie slices, scatter categories must not be merged: each point is + // still an observation. Keep the largest K categories distinct, paint the + // tail with the overflow ink, and collapse only its legend entries. + const pointOverflowInks = new Map(); + let pointOverflowHandled = false; + if (s.overflowTail && s.overflow && s.categorical.length) { + const points = traces.filter((trace) => { + const families = markFamilies(trace); + return trace?.name + && families.includes('point') + && !families.includes('line') + && !families.includes('area') + && !Array.isArray(trace.marker?.color); + }); + if (points.length > s.categorical.length) { + const ranked = points + .map((trace, index) => ({ + trace, + index, + count: Math.max(trace.x?.length ?? 0, trace.y?.length ?? 0), + })) + .sort((a, b) => (b.count - a.count) || (a.index - b.index)); + const keep = s.categorical.length; + ranked.slice(0, keep).forEach(({ trace }, index) => { + pointOverflowInks.set(trace, s.categorical[index]); + trace.showlegend = true; + }); + const tail = ranked.slice(keep); + for (const { trace } of tail) { + pointOverflowInks.set(trace, s.overflow); + trace.showlegend = false; + } + figure.data.push({ + type: 'scatter', + mode: 'markers', + name: `Others (${tail.length})`, + x: [null], + y: [null], + marker: { color: s.overflow }, + hoverinfo: 'skip', + showlegend: true, + _themeRole: 'overflow-legend-proxy', + }); + pointOverflowHandled = true; + say( + 'ink.series.categorical', + `${points.length} point categories past ${keep} inks — observations stay separate while the legend tail shares one "Others (${tail.length})" ink`, + ); + } + } + + if (s.exhausted && !pointOverflowHandled) { + say('ink.series', 'more series than the house names inks for — template palette kept'); + return; + } + + // A continuous colour channel: the ramp goes on the colorscale, not on a + // per-trace colour. + const ramped = traces.filter((t) => t.colorscale != null || t.marker?.colorscale != null); + if (ramped.length && (s.mode === 'sequential' || s.mode === 'diverging' || s.ramp)) { + const stops = s.range ?? s.ramp?.stops; + if (stops?.length) { + const scale = stops.map((c, i) => [stops.length === 1 ? 0 : i / (stops.length - 1), c] as [number, string]); + for (const t of ramped) { + if (t.colorscale != null) t.colorscale = scale; + if (t.marker?.colorscale != null) t.marker.colorscale = scale; + } + } + } + + // Per-series inks. One trace per series is the common Plotly shape; a + // single trace with an array of colours (a bar chart coloured by category) + // is the other. + const inks = s.mode === 'single' ? [s.single] : s.categorical; + if (!inks?.length) return; + + const recoloured = new Map(); + let seriesIndex = 0; + for (const trace of traces) { + if (trace.colorscale != null || trace.marker?.colorscale != null) continue; + const overflowInk = pointOverflowInks.get(trace); + if (overflowInk) { + trace.marker = { ...(trace.marker ?? {}), color: overflowInk }; + continue; + } + const fams = markFamilies(trace); + + // A pie states one colour per slice on `marker.colors` — the whole + // series lives in one trace. + if (Array.isArray(trace.marker?.colors)) { + if (foldedPies.has(trace)) continue; + trace.marker.colors = (trace.marker.colors as any[]).map( + (c: any, i: number) => (isLiteralColor(c) ? inks[i % inks.length] : c), + ); + continue; + } + + // A waterfall or a candlestick states its colours by *direction*, in + // `increasing`/`decreasing`/`totals` blocks. Direction is not a series, + // so the categorical set says nothing about it — the house's status + // inks do. + if (trace.increasing || trace.decreasing || trace.totals) { + paintDirectional(trace, d, say); + continue; + } + + const perPoint = Array.isArray(trace.marker?.color) + ? (trace.marker.color as unknown[]).filter(isLiteralColor) + : null; + if (perPoint && perPoint.length) { + // One trace, many colours: keep the mapping the template made, but + // restate each distinct colour in the house's set, in the order the + // template introduced them. + const seen = new Map(); + trace.marker.color = (trace.marker.color as any[]).map((c: any) => { + if (!isLiteralColor(c)) return c; + if (!seen.has(c)) seen.set(c, inks[seen.size % inks.length]); + return seen.get(c); + }); + continue; + } + + // The wedge between funnel stages is furniture, not data: it takes a + // quiet mix of the surface and the text, like a grid line would. + if (trace.connector) { + const quiet = mixHex(d.surface.plot ?? d.surface.canvas, d.text.primary, 0.25); + if (trace.connector.fillcolor != null) trace.connector.fillcolor = quiet; + if (trace.connector.line) trace.connector.line = { ...trace.connector.line, color: quiet }; + } + + const declaredIndex = Number(trace?._seriesIndex); + const inkIndex = Number.isInteger(declaredIndex) && declaredIndex >= 0 + ? declaredIndex + : seriesIndex; + const ink = inks[inkIndex % inks.length]; + const was = trace.line?.color ?? trace.marker?.color; + if (typeof was === 'string' && isLiteralColor(was)) recoloured.set(was.toLowerCase(), ink); + if (fams.includes('bar') || fams.includes('arc') || fams.includes('boxplot')) { + trace.marker = { ...(trace.marker ?? {}), color: ink }; + if (trace.type === 'violin' || trace.type === 'box') trace.line = { ...(trace.line ?? {}), color: ink }; + } + if (fams.includes('line')) { + trace.line = { ...(trace.line ?? {}), color: ink }; + } + if (fams.includes('point')) { + trace.marker = { ...(trace.marker ?? {}), color: ink }; + } + if (fams.includes('area')) { + // Bands in a stack sit side by side, not on top of one another, so + // there is nothing behind them to see; translucency there only + // muddies two colours into a third the house never chose. + const alpha = trace.type === 'scatterpolar' && trace.fill === 'toself' + ? Math.min(0.16, d.marks.fillOpacity ?? 0.16) + : trace.stackgroup != null ? 1 : d.marks.fillOpacity ?? 0.8; + trace.fillcolor = withAlpha(ink, alpha); + } + seriesIndex++; + } + + // Plotly's built-in label+percent formatter always stacks the two lines. + // Re-evaluate after overflow folding, because that pass can replace both + // labels and values (including a newly named Others slice). + for (const trace of traces) { + const parts = typeof trace?.textinfo === 'string' ? trace.textinfo.split('+') : []; + if (trace?.type !== 'pie' + || !Array.isArray(trace.labels) + || !parts.includes('label') + || !parts.includes('percent')) continue; + trace.texttemplate = pieCategoryPercentTemplates( + trace.labels, + Number(figure.layout?.width) || Number(figure._width) || 400, + d.dataLabels.text.fontSize ?? 11, + ); + } + + restateSeriesAnnotations(figure, recoloured, say); + + void table; + void distinctCount; +} + +/** + * An annotation printed in a series' colour — the number at the end of a + * sparkline row — is naming that series. When the series changes ink, so does + * the annotation, or the row says one thing in two colours. + */ +function restateSeriesAnnotations(figure: any, recoloured: Map, say: Say): void { + if (!recoloured.size) return; + let moved = 0; + for (const note of figure.layout?.annotations ?? []) { + const colour = note?.font?.color; + if (typeof colour !== 'string') continue; + const ink = recoloured.get(colour.toLowerCase()); + if (!ink || ink.toLowerCase() === colour.toLowerCase()) continue; + note.font = { ...note.font, color: ink }; + moved++; + } + if (moved) say('ink.series', `${moved} annotation(s) named a series and followed its ink`); +} + +function withAlpha(hex: string, alpha: number): string { + const c = parseColor(hex); + if (!c) return hex; + return `rgba(${c.r}, ${c.g}, ${c.b}, ${Math.max(0, Math.min(1, alpha))})`; +} + +// --------------------------------------------------------------------------- +// Legend +// --------------------------------------------------------------------------- + +const LEGEND_ANCHORS: Record = { + top: { x: 0, y: 1.12, xanchor: 'left', yanchor: 'bottom', orientation: 'h' }, + bottom: { x: 0, y: -0.2, xanchor: 'left', yanchor: 'top', orientation: 'h' }, + right: { x: 1.02, y: 1, xanchor: 'left', yanchor: 'top', orientation: 'v' }, + left: { x: -0.2, y: 1, xanchor: 'right', yanchor: 'top', orientation: 'v' }, + 'top-left': { x: 0.02, y: 0.98, xanchor: 'left', yanchor: 'top', orientation: 'v' }, + 'top-right': { x: 0.98, y: 0.98, xanchor: 'right', yanchor: 'top', orientation: 'v' }, + 'bottom-left': { x: 0.02, y: 0.02, xanchor: 'left', yanchor: 'bottom', orientation: 'v' }, + 'bottom-right': { x: 0.98, y: 0.02, xanchor: 'right', yanchor: 'bottom', orientation: 'v' }, +}; + +function addWaterfallLegendProxies(figure: any): void { + if ((figure.data ?? []).some((trace: any) => trace?._themeRole === 'waterfall-legend-proxy')) return; + const waterfall = (figure.data ?? []).find((trace: any) => trace?.type === 'waterfall'); + if (!waterfall) return; + const roles = [ + ['Total', waterfall.totals?.marker?.color], + ['Increase', waterfall.increasing?.marker?.color], + ['Decrease', waterfall.decreasing?.marker?.color], + ]; + for (const [name, color] of roles) { + if (!color) continue; + figure.data.push({ + type: 'scatter', + mode: 'markers', + name, + x: [null], + y: [null], + marker: { color, symbol: 'square', size: 9 }, + hoverinfo: 'skip', + showlegend: true, + _themeRole: 'waterfall-legend-proxy', + }); + } +} + +function addFactoredLineLegendProxies(figure: any, d: DesignDecisions): void { + const traces = (figure.data ?? []).filter((trace: any) => + trace?._colorLegendValue && trace?._dashLegendValue + && trace?._themeRole !== 'factored-line-legend-proxy'); + const colors = new Map(); + const dashes = new Map(); + for (const trace of traces) { + if (!colors.has(trace._colorLegendValue)) { + colors.set(trace._colorLegendValue, trace.line?.color); + } + if (!dashes.has(trace._dashLegendValue)) { + dashes.set(trace._dashLegendValue, trace.line?.dash ?? 'solid'); + } + } + for (const trace of traces) trace.showlegend = false; + const existing = (figure.data ?? []).filter((trace: any) => + trace?._themeRole === 'factored-line-legend-proxy'); + if (existing.length) { + for (const trace of existing) { + if (trace._colorLegendValue && colors.has(trace._colorLegendValue)) { + trace.line = { ...trace.line, color: colors.get(trace._colorLegendValue) }; + } else if (trace._dashLegendValue) { + trace.line = { ...trace.line, color: d.text.secondary ?? d.text.primary }; + } + } + return; + } + if (colors.size < 2 || dashes.size < 2) return; + const proxy = (name: string, color: string, dash: string) => ({ + type: 'scatter', + mode: 'lines', + name, + x: [null], + y: [null], + line: { color, dash, width: 2.5 }, + hoverinfo: 'skip', + showlegend: true, + _themeRole: 'factored-line-legend-proxy', + }); + for (const [name, color] of colors) { + figure.data.push(proxy(name, color, 'solid')); + } + for (const [name, dash] of dashes) { + figure.data.push(proxy(name, d.text.secondary ?? d.text.primary, dash)); + } +} + +function applyLegend(figure: any, d: DesignDecisions, say: Say): number { + const layout = figure.layout; + const l = d.legend; + + if (!l.show) { + layout.showlegend = false; + for (const trace of figure.data ?? []) { + if (trace?.marker?.colorbar) trace.marker.showscale = false; + if (trace?.colorbar) trace.showscale = false; + if (trace?.showscale) trace.showscale = false; + } + return 0; + } + + addWaterfallLegendProxies(figure); + addFactoredLineLegendProxies(figure, d); + + const visiblePies = (figure.data ?? []).filter((trace: any) => + trace?.type === 'pie' && trace?.visible !== false); + const legendBearingTraces = (figure.data ?? []).filter((trace: any) => + trace?.visible !== false + && trace?.showlegend !== false + && (Array.isArray(trace?.labels) || trace?.name)); + const everyPieSliceNamed = visiblePies.length > 0 + && legendBearingTraces.every((trace: any) => trace?.type === 'pie') + && visiblePies.every((trace: any) => + Array.isArray(trace.labels) + && trace.labels.length > 0 + && trace.labels.every((label: any) => String(label).trim().length > 0) + && typeof trace.textinfo === 'string' + && trace.textinfo.split('+').includes('label')); + if (everyPieSliceNamed) { + layout.showlegend = false; + say( + 'legend.show', + 'legend omitted — every pie slice is already named on the chart, so a second copy would compete with its callout', + ); + return 0; + } + + // `seriesEnd` names each series at the end of its own line. Realized as + // annotations where there is a line to end; otherwise the house's own + // fallback is taken rather than a placement invented here. + let placement: string = l.placement; + if (placement === 'seriesEnd') { + if (labelSeriesEnds(figure, d, say)) { + layout.showlegend = false; + return 0; + } + placement = (l.fallbacks ?? []).find((p) => p !== 'seriesEnd') ?? 'right'; + say('legend.placement', `no series with an end to name — fell back to \`${placement}\``); + } + if (!LEGEND_ANCHORS[placement]) { + const next = (l.fallbacks ?? []).find((p) => LEGEND_ANCHORS[p]) ?? 'right'; + say('legend.placement', `\`${placement}\` is not a Plotly placement — fell back to \`${next}\``); + placement = next; + } + + const anchor = LEGEND_ANCHORS[l.orient ?? placement] ?? LEGEND_ANCHORS[placement] ?? LEGEND_ANCHORS.right; + layout.showlegend = true; + layout.legend = { + ...(layout.legend ?? {}), + ...anchor, + ...(l.direction ? { orientation: l.direction === 'horizontal' ? 'h' : 'v' } : {}), + // Plotly turns a stacked chart's key upside down on its own, so a + // likert row running "a great deal → none at all" gets a key that + // reads "none at all → a great deal". The key names a scale, and a + // scale has one order: the one the traces are in. + traceorder: 'normal', + font: fontOf(l.label, d.font), + bgcolor: 'rgba(0,0,0,0)', + borderwidth: 0, + title: l.title ? (layout.legend?.title ?? undefined) : { text: '' }, + }; + + // A key to values is a colorbar, not a swatch list. + for (const trace of figure.data ?? []) { + const bar = trace?.marker?.colorbar ?? trace?.colorbar; + if (!bar) continue; + Object.assign(bar, { + outlinewidth: 0, + tickfont: fontOf(l.label, d.font), + ...(l.gradientLength ? { len: l.gradientLength, lenmode: 'pixels' } : {}), + ...(l.title ? {} : { title: { text: '' } }), + }); + } + + // A key above the plot is chrome the figure has to pay for, however it is + // stacked. + if (layout.legend.y > 1) { + if (layout.legend.orientation === 'h') { + return horizontalLegendHeight(figure, l.label.fontSize ?? 11, needsMarkup(l.label).bold); + } + const rows = legendEntries(figure).length; + return rows ? rows * ((l.label.fontSize ?? 11) + 12) + 8 : 0; + } + // A key beside the plot is paid for in width, for the same reason. + if (layout.legend.orientation === 'v' && layout.legend.x >= 1) { + const entries = legendEntries(figure); + if (entries.length) { + const size = l.label.fontSize ?? 11; + const need = Math.ceil(Math.max(...entries.map((e) => e.length)) * size * 0.6) + 40; + const margin = (layout.margin ??= {}); + const before = margin.r ?? 0; + margin.r = Math.max(before, need); + const grew = margin.r - before; + if (grew > 0 && Number(layout.width)) layout.width = Math.round(layout.width + grew); + } + } + + // A column of keys taller than the plot is simply cut off at the bottom, + // so the figure grows to hold it. + if (layout.legend.orientation !== 'h') { + const entries = legendEntries(figure); + const need = entries.length * ((l.label.fontSize ?? 11) + 14) + 20; + const have = (Number(layout.height) || 0) - (layout.margin?.t ?? 0) - (layout.margin?.b ?? 0); + if (entries.length && have > 0 && need > have) { + layout.height = Math.round(Number(layout.height) + (need - have)); + say('legend.placement', `the figure grew ${Math.round(need - have)}px to hold a column of ${entries.length} keys`); + } + } + return 0; +} + +/** The names a key will carry: one per trace, or one per slice of a pie. */ +function legendEntries(figure: any): string[] { + const out: string[] = []; + for (const t of figure.data ?? []) { + if (t?.showlegend === false) continue; + if (Array.isArray(t?.labels)) out.push(...t.labels.map(String)); + else if (t?.name) out.push(String(t.name)); + } + return out; +} + +/** How tall a horizontal key stacks once its entries are packed into the width. */ +function horizontalLegendHeight(figure: any, fontSize: number, bold = false): number { + const entries = legendEntries(figure); + if (!entries.length) return 0; + // A key above the plot is laid out across the *plot area*, not the paper, + // so it wraps sooner than the figure width suggests. + const layout = figure.layout ?? {}; + const width = Math.max( + 120, + (Number(layout.width) || 400) - (layout.margin?.l ?? 0) - (layout.margin?.r ?? 0), + ); + const rowHeight = fontSize + 14; + let rows = 1; + let used = 0; + for (const e of entries) { + const w = e.length * fontSize * (bold ? 0.72 : 0.62) + 46; + if (used > 0 && used + w > width) { + rows++; + used = w; + } else used += w; + } + return rows * rowHeight + 8; +} + +/** + * Where an annotation must sit to land on this value. + * + * A category axis is numbered from zero in the order the categories appear, so + * an annotation naming one has to state its serial number. + */ +function categoryPosition(ax: any, value: any): any { + if (ax?.type !== 'category') return value; + const cats = Array.isArray(ax.categoryarray) ? ax.categoryarray : null; + const i = cats ? cats.findIndex((c: any) => String(c) === String(value)) : -1; + return i >= 0 ? i : value; +} + +/** + * Push a stack of end-of-line names apart so none sits on another. + * + * Series that finish close together would otherwise print their names on the + * same few pixels. The names are nudged in data units — the annotation still + * points at the line's real last value, it is only shifted enough to be read. + */ +function dodgeVertically(notes: any[], figure: any, fontSize: number, plotted: number[]): void { + if (notes.length < 2) return; + const layout = figure.layout; + const values = notes.map((a) => Number(a.y)).filter((v) => Number.isFinite(v)); + if (values.length !== notes.length) return; + + // A text line is worth a share of the *axis*, not of the band the names + // happen to fall in: measuring against the names alone under-counts the + // gap several times over on a chart whose scale starts at zero. + const ax = layout[(notes[0].yref ?? 'y').replace('y', 'yaxis')] ?? {}; + const all = plotted.filter((v) => Number.isFinite(v)); + let span: number; + if (Array.isArray(ax.range) && ax.range.length === 2) span = Math.abs(Number(ax.range[1]) - Number(ax.range[0])); + else if (all.length) span = Math.max(...all, ax.rangemode === 'tozero' ? 0 : -Infinity) - Math.min(...all, 0); + else span = Math.max(...values) - Math.min(...values); + if (!span || !Number.isFinite(span)) return; + const plotH = Math.max( + 40, + (Number(layout.height) || 300) - (layout.margin?.t ?? 0) - (layout.margin?.b ?? 0), + ); + // The whole axis is taller than the band the names occupy, so this is a + // conservative (over-)estimate of how many data units a text line is worth. + const gap = (fontSize * 1.25 * span) / plotH; + const order = notes.slice().sort((a, b) => a.y - b.y); + for (let i = 1; i < order.length; i++) { + const below = Number(order[i - 1].y); + if (Number(order[i].y) - below < gap) order[i].y = below + gap; + } +} + +/** + * Where each trace is actually *drawn*, in the coordinates the axis shows. + * + * A stacked trace is not drawn at its own value: Plotly lifts it onto the ones + * before it in its `stackgroup`, and normalizes the whole group to 100 when the + * first trace in that group says `groupnorm: 'percent'`. Reading a stacked + * series' position off `t.y` therefore names a number the chart never plots — + * and since an annotation takes part in the autorange, it drags the scale out + * with it. Filled bands are named at their middle, bare lines at their top. + */ +function plottedPositions(traces: any[]): Map { + const out = new Map(); + const groups = new Map(); + for (const t of traces) { + if (t.stackgroup == null) { + out.set(t, (t.y ?? []).map((v: any) => Number(v))); + continue; + } + const key = `${t.xaxis ?? 'x'}|${t.yaxis ?? 'y'}|${t.stackgroup}`; + const bucket = groups.get(key); + if (bucket) bucket.push(t); + else groups.set(key, [t]); + } + for (const group of groups.values()) { + const norm = group.some((t) => t.groupnorm === 'percent'); + const n = Math.max(0, ...group.map((t) => (t.y ?? []).length)); + const totals: number[] = []; + for (let i = 0; i < n; i++) { + totals[i] = group.reduce((sum, t) => sum + (Number(t.y?.[i]) || 0), 0); + } + const running = new Array(n).fill(0); + for (const t of group) { + const banded = t.fill !== 'none' && markFamilies(t).includes('area'); + const at: number[] = []; + for (let i = 0; i < n; i++) { + const v = Number(t.y?.[i]) || 0; + const point = banded ? running[i] + v / 2 : running[i] + v; + running[i] += v; + at[i] = norm ? (totals[i] ? (point / totals[i]) * 100 : 0) : point; + } + out.set(t, at); + } + } + return out; +} + +/** + * Name each series at the end of its own line. + * + * Returns false where nothing on the chart has an end to name — a bar chart + * has no last point — so the caller can take the house's next placement. + */ +function labelSeriesEnds(figure: any, d: DesignDecisions, say: Say): boolean { + let lines = dataTraces(figure).filter((t) => { + const fams = markFamilies(t); + return (fams.includes('line') || fams.includes('area')) && Array.isArray(t.x) && Array.isArray(t.y) && t.name; + }); + const factored = lines.filter(t => t._colorLegendValue && t._dashLegendValue); + if (factored.length) { + const lastByColor = new Map(); + const endpointRank = (trace: any): number => { + const values = trace.x ?? []; + let value: any; + for (let i = values.length - 1; i >= 0; i--) { + if (values[i] != null) { value = values[i]; break; } + } + const axisKey = String(trace.xaxis ?? 'x').replace('x', 'xaxis'); + const axis = figure.layout?.[axisKey]; + if (axis?.type === 'category') return categoryPosition(axis, value); + if (axis?.type === 'date') return new Date(value).getTime(); + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : values.length; + }; + for (const trace of factored) { + const prior = lastByColor.get(trace._colorLegendValue); + if (!prior || endpointRank(trace) >= endpointRank(prior)) { + lastByColor.set(trace._colorLegendValue, trace); + } + } + lines = [ + ...lines.filter(t => !(t._colorLegendValue && t._dashLegendValue)), + ...lastByColor.values(), + ]; + } + if (lines.length < 1) return false; + + const layout = figure.layout; + const annotations = (layout.annotations ??= []); + const drawnAt = plottedPositions(lines); + const placed: any[] = []; + for (const t of lines) { + const n = t.x.length; + if (!n) continue; + const axisKey = (t.xaxis ?? 'x').replace('x', 'xaxis'); + const drawn = drawnAt.get(t) ?? []; + placed.push({ + // On a category axis Plotly reads an annotation's `x` as the + // category's *serial number*, not its name — naming the category + // there pushes the annotation thousands of bands to the right and + // drags the scale with it. + x: categoryPosition(layout[axisKey], t.x[n - 1]), + y: drawn[n - 1] ?? t.y[n - 1], + xref: t.xaxis ?? 'x', + yref: t.yaxis ?? 'y', + text: styleText(String(t._colorLegendValue ?? t.name), d.legend.label), + showarrow: false, + xanchor: 'left', + xshift: 6, + font: { + ...fontOf(d.legend.label, d.font), + color: t.line?.color ?? t.marker?.color ?? d.legend.label.color, + }, + }); + } + const spanValues: number[] = []; + for (const t of lines) for (const v of drawnAt.get(t) ?? []) if (Number.isFinite(v)) spanValues.push(v); + dodgeVertically(placed, figure, d.legend.label.fontSize ?? 11, spanValues); + annotations.push(...placed); + + // The names sit outside the plotting rectangle; make room for them. + const longest = Math.max(...lines.map((t) => String(t._colorLegendValue ?? t.name).length)); + const pad = Math.ceil(longest * (d.legend.label.fontSize ?? 11) * 0.55) + 12; + layout.margin = { ...(layout.margin ?? {}), r: Math.max(layout.margin?.r ?? 0, pad) }; + say('legend.placement', `series named at the end of each line (${lines.length})`); + return true; +} + +// --------------------------------------------------------------------------- +// Facet chrome +// --------------------------------------------------------------------------- + +/** + * Is this annotation a panel's name? + * + * Templates that emit a facet grid mark the header for us; the rest are + * recognised by where they sit — pinned to the top of the paper, over a panel, + * with no arrow. Getting this wrong only means a caption is typed as a header. + */ +function isFacetHeader(a: any): boolean { + if (a?._role === 'facet-header') return true; + return a?.xref === 'paper' && a?.yref === 'paper' && a?.showarrow === false + && a?.xanchor === 'center' && a?.yanchor === 'top'; +} + +function applyFacetChrome(figure: any, d: DesignDecisions): void { + const f = d.facets.header; + for (const a of figure.layout?.annotations ?? []) { + if (!isFacetHeader(a)) continue; + if (!f.show) { + a.text = ''; + continue; + } + a.font = { ...(a.font ?? {}), ...fontOf(f, d.font) }; + a.text = styleText(String(a.text ?? '').replace(/^.*?: /, f.fieldTitle ? '$&' : ''), f); + } +} + +// --------------------------------------------------------------------------- +// Data labels +// --------------------------------------------------------------------------- + +/** Is there a key on this figure that already names the slices of a pie? */ +function legendNamesSlices(figure: any, d: DesignDecisions): boolean { + if (d.legend.placement === 'seriesEnd') return false; + return figure?.layout?.showlegend !== false; +} + +/** + * Does this axis show unsigned labels for signed values? + * + * That is how a mirrored chart — a pyramid, a diverging bar — is built: one + * side is negative and the axis relabels it. It is a fact about the compiled + * chart, readable without knowing which chart type made it. + */ +function isMirroredAxis(ax: any): boolean { + const vals = ax?.tickvals; + const text = ax?.ticktext; + if (!Array.isArray(vals) || !Array.isArray(text) || vals.length !== text.length) return false; + return vals.some((v: any, i: number) => Number(v) < 0 && !String(text[i]).includes('-') + && !String(text[i]).includes('\u2212')); +} + +/** + * Blank the labels of segments too thin to hold one. + * + * `texttemplate` takes an array, one entry per point, so a per-segment + * decision is expressible. Returns how many were dropped. + */ +function blankSmallSegments(trace: any, figure: any, measure: 'x' | 'y', fontSize: number): number { + const values = trace[measure]; + if (!Array.isArray(values)) return 0; + const layout = figure.layout ?? {}; + const across = measure === 'y' + ? (Number(layout.height) || 300) - (layout.margin?.t ?? 0) - (layout.margin?.b ?? 0) + : (Number(layout.width) || 400) - (layout.margin?.l ?? 0) - (layout.margin?.r ?? 0); + + // The stack, not the trace, sets the scale: sum what every trace of the + // same orientation contributes to each band. + const totals: number[] = []; + for (const t of figure.data ?? []) { + const v = t?.[measure]; + if (!Array.isArray(v) || String(t.type ?? '') !== 'bar') continue; + v.forEach((n: any, i: number) => { + totals[i] = (totals[i] ?? 0) + Math.abs(Number(n) || 0); + }); + } + const span = Math.max(...totals.filter(Number.isFinite), 0); + if (!span || !Number.isFinite(across) || across <= 0) return 0; + + const min = (span * fontSize * 1.8) / across; + const template = String(trace.texttemplate); + let dropped = 0; + trace.texttemplate = values.map((v: any) => { + if (Math.abs(Number(v) || 0) >= min) return template; + dropped++; + return ''; + }); + return dropped; +} + +/** Trace families that can print a number on the mark. */ +const LABELABLE = new Set(['bar', 'arc', 'point', 'line']); + +function applyDataLabels(figure: any, d: DesignDecisions, table: any[], say: Say): void { + const dl = d.dataLabels; + if (!dl.show) return; + + const traces = dataTraces(figure).filter((t) => markFamilies(t).some((m) => LABELABLE.has(m))); + if (!traces.length) { + say('dataLabels.show', 'nothing on this chart can carry a printed value'); + return; + } + + const fmt = dl.format ? `:${dl.format}` : ''; + const unitText = dl.unit ?? ''; + // A currency sign leads the number; a word unit trails it after a space. + const unitPrefix = /^[$£€¥]$/.test(unitText) ? unitText : ''; + const unit = unitPrefix ? '' + : /^[A-Za-z]/.test(unitText) ? ` ${unitText}` + : unitText; + let printed = 0; + + for (const trace of traces) { + const fams = markFamilies(trace); + // A trace with no numbers in it has nothing to print. A legend proxy + // — one empty bar standing in for a colour — is the usual case, and + // labelling it writes NaN on the axis. + if (!fams.includes('arc') && !numericChannel(trace)) continue; + if (trace.text != null || trace.texttemplate != null || trace.textinfo != null) { + // The template prints its own numbers. The theme may not change + // *what* is written — that was the template's decision — but the + // type it is written in is the house's. + trace.textfont = { ...(trace.textfont ?? {}), ...fontOf(dl.text, d.font) }; + if (dl.inkMode === 'contrastWithMark' && trace.textfont) delete trace.textfont.color; + // Except for one thing the template could not know: whether the + // slice's name is also in a key. Printing it twice is what makes a + // 5-per-cent wedge unreadable, and the second copy is in the + // smaller, more crowded place. + if (fams.includes('arc') && legendNamesSlices(figure, d) + && typeof trace.textinfo === 'string' && /\blabel\b/.test(trace.textinfo)) { + const rest = trace.textinfo.split('+').filter((part: string) => part !== 'label'); + if (rest.length) { + trace.textinfo = rest.join('+'); + say('label.text', 'slice names left to the key — printed on the wedge as well, they crowd it out'); + } + } + printed++; + continue; + } + if (fams.includes('arc')) { + trace.textinfo = 'value'; + trace.texttemplate = `${unitPrefix}%{value${fmt}}${unit}`; + trace.textposition = dl.placement === 'outsideMark' ? 'outside' : 'inside'; + trace.textfont = fontOf(dl.text, d.font); + if (dl.inkMode === 'contrastWithMark') delete trace.textfont.color; + printed++; + continue; + } + + if (fams.includes('bar')) { + const measure = trace.orientation === 'h' ? 'x' : (numericChannel(trace) ?? 'y'); + const normalized = figure.layout?.barnorm === 'percent'; + if (normalized) { + const barTraces = dataTraces(figure).filter((t) => + markFamilies(t).includes('bar') + && Array.isArray(t[measure]) + && String(t[`${measure}axis`] ?? measure) === String(trace[`${measure}axis`] ?? measure) + && String(t.offsetgroup ?? '') === String(trace.offsetgroup ?? '')); + const values = Array.isArray(trace[measure]) ? trace[measure] : []; + trace.customdata = values.map((value: any, index: number) => { + const denominator = barTraces.reduce( + (sum, t) => sum + (Number(t[measure]?.[index]) || 0), + 0, + ); + return denominator ? (Number(value) || 0) / Math.abs(denominator) * 100 : null; + }); + const percentMatch = dl.format?.match(/^\.(\d+)%$/); + const normalizedFmt = percentMatch ? `:.${percentMatch[1]}f` : fmt; + trace.texttemplate = `%{customdata${normalizedFmt}}%`; + } else { + trace.texttemplate = `${unitPrefix}%{${measure}${fmt}}${unit}`; + } + // Plotly places the label inside where it fits and outside where it + // does not, which is exactly the geometry stage 2 computed with + // `insideMinValue`/`outsideMaxValue`. `auto` hands that decision to + // the renderer, which can measure the drawn bar; `outside` is + // honoured literally because it is a house habit, not a fit. + // A segment of a stack has no outside — "outside" is the middle of + // the neighbouring segment — so a label that will not fit inside is + // dropped instead of moved, and it is never turned on its side. + const stacked = /^(stack|relative)$/.test(String(figure.layout?.barmode ?? '')); + // A population pyramid states one side of the split as negative + // numbers and then hides the sign on the axis. The label has to + // tell the same story the axis does. + const axKey = measure === 'x' + ? String(trace.xaxis ?? 'x').replace('x', 'xaxis') + : String(trace.yaxis ?? 'y').replace('y', 'yaxis'); + const mirrored = isMirroredAxis(figure.layout?.[axKey]); + if (mirrored) { + const abs = (trace[measure] as any[]).map((v) => Math.abs(Number(v) || 0)); + const cd = trace.customdata; + // The template may already be carrying the unsigned value for + // its own tooltip; reuse it rather than trample it. + const reusable = Array.isArray(cd) && cd.length === abs.length + && cd.every((v: any, i: number) => Number(v) === abs[i]); + if (cd == null || reusable) { + if (cd == null) trace.customdata = abs; + trace.texttemplate = `${unitPrefix}%{customdata${fmt}}${unit}`; + say('dataLabels.text', 'a mirrored measure prints its labels unsigned, as its axis does'); + } else { + say('dataLabels.text', 'a mirrored measure kept its signed label — the trace needs its own customdata'); + } + } + trace.textposition = stacked || mirrored + ? 'inside' + : dl.placement === 'outsideMark' ? 'outside' : 'auto'; + trace.textangle = 0; + if (stacked) { + trace.insidetextanchor = 'middle'; + trace.constraintext = 'both'; + // Left to itself Plotly shrinks a label until it fits its + // segment, which puts three type sizes on one chart. A segment + // too thin for the house's size loses its label instead. + const dropped = blankSmallSegments(trace, figure, measure, dl.text.fontSize ?? 11); + if (dropped) { + say( + 'dataLabels.show', + `${dropped} stacked segment(s) too thin to hold a label at the house's size`, + ); + } + say('dataLabels.placement', 'stacked segments keep their labels inside, or drop them'); + } + trace.cliponaxis = false; + trace.textfont = fontOf(dl.text, d.font); + if (dl.inkMode === 'contrastWithMark') { + // Plotly picks a contrasting ink itself when none is stated + // *inside* the bar, and uses `outsidetextfont` beyond it. + delete trace.textfont.color; + trace.outsidetextfont = fontOf(dl.text, d.font); + } + printed++; + continue; + } + + if (fams.includes('point') || fams.includes('line')) { + // A point series is usually measured up the page, but not always: + // labelling a category with `%{y}` prints NaN. + const measure = numericChannel(trace); + if (!measure) continue; + trace.mode = String(trace.mode ?? 'lines').includes('text') + ? trace.mode + : `${trace.mode ?? 'lines'}+text`; + trace.texttemplate = `${unitPrefix}%{${measure}${fmt}}${unit}`; + trace.textposition = 'top center'; + trace.textfont = fontOf(dl.text, d.font); + trace.cliponaxis = false; + printed++; + } + } + + if (!printed) say('dataLabels.show', 'every mark already prints its own text — theme labels stood down'); + void table; + void mixHex; + void toHex; + void isDarkSurface; + void contrastingInk; + void sampleRamp; +} + +/** Which of a trace's two channels carries the number worth printing. */ +function numericChannel(trace: any): 'x' | 'y' | null { + const numeric = (v: any) => Array.isArray(v) && v.some((n) => typeof n === 'number' && Number.isFinite(n)); + if (numeric(trace?.y)) return 'y'; + if (numeric(trace?.x)) return 'x'; + return null; +} + +/** + * Break a Plotly title to the width it has, with no house in sight. + * + * Plotly neither wraps a title nor reserves room for one, so a headline longer + * than the figure is simply cut off at both ends. That is Flint's own bug, not + * a theme's, so it is fixed on the untheme'd path too. + */ +export function fitPlotlyTitle(figure: any): void { + const layout = figure?.layout; + const title = layout?.title; + const text = typeof title === 'string' ? title : title?.text; + if (!text) return; + const size = title?.font?.size ?? 17; + const width = Number(layout.width) || 400; + const lines = wrapToWidth(String(text).replace(/
/g, ' '), width - 16, size); + const deck = title?._deck as string | undefined; + const all = deck ? [...lines, ...wrapToWidth(deck, width - 16, size * 0.75)] : lines; + layout.title = { + ...(typeof title === 'object' ? title : {}), + text: deck + ? `${lines.join('
')}
${deck}` + : lines.join('
'), + x: 0.5, + xanchor: 'center', + xref: 'container', + y: titleY(layout, all.length, size), + yanchor: 'top', + yref: 'container', + }; + const need = 8 + titleBlockHeight(all.length, size) + 14; + const margin = (layout.margin ??= {}); + const before = margin.t ?? 0; + margin.t = Math.max(before, need); + const grew = margin.t - before; + if (grew > 0 && Number(layout.height)) layout.height = Math.round(layout.height + grew); + layout.title.y = titleY(layout, all.length, size); +} diff --git a/packages/flint-js/src/test-data/calendar-tests.ts b/packages/flint-js/src/test-data/calendar-tests.ts new file mode 100644 index 00000000..baf7af9f --- /dev/null +++ b/packages/flint-js/src/test-data/calendar-tests.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Type } from './df-types'; +import { seededRandom } from './generators'; +import { makeEncodingItem, makeField, type TestCase } from './types'; + +export function genCalendarTests(): TestCase[] { + const rand = seededRandom(711); + const start = new Date('2024-01-01T00:00:00Z'); + const data = Array.from({ length: 121 }, (_, index) => { + const date = new Date(start); + date.setUTCDate(start.getUTCDate() + index); + const weekend = date.getUTCDay() === 0 || date.getUTCDay() === 6; + return { + Date: date.toISOString().slice(0, 10), + Activity: Math.max(0, Math.round((weekend ? 25 : 60) + rand() * 40)), + }; + }); + + return [{ + title: 'Daily Activity, January–April 2024', + description: 'Four months of daily activity in a Monday-first calendar grid.', + tags: ['calendar', 'heatmap', 'temporal'], + chartType: 'Calendar Heatmap', + data, + fields: [makeField('Date'), makeField('Activity')], + metadata: { + Date: { type: Type.Date, semanticType: 'Date', levels: [] }, + Activity: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + }, + encodingMap: { + x: makeEncodingItem('Date'), + color: makeEncodingItem('Activity'), + }, + }]; +} diff --git a/packages/flint-js/src/test-data/index.ts b/packages/flint-js/src/test-data/index.ts index e59fc3ad..3b441185 100644 --- a/packages/flint-js/src/test-data/index.ts +++ b/packages/flint-js/src/test-data/index.ts @@ -34,6 +34,7 @@ export { genWaterfallTests, genBarTableTests, genCandlestickTests, genRadarTests, genPyramidTests, genRoseTests, } from './specialized-tests'; +export { genCalendarTests } from './calendar-tests'; export { FACET_SIZES, DISCRETE_SIZES, genFacetColumnTests, genFacetRowTests, genFacetColRowTests, genFacetSmallTests, genFacetWrapTests, genFacetClipTests, genFacetOverflowedColTests, genFacetOverflowedColRowTests, genFacetOverflowedRowTests, genFacetDenseLineTests } from './facet-tests'; export { genOverflowTests, genElasticityTests } from './stress-tests'; export { genGasPressureTests } from './gas-pressure-tests'; @@ -106,6 +107,7 @@ import { genWaterfallTests, genBarTableTests, genCandlestickTests, genRadarTests, genPyramidTests, genRoseTests, } from './specialized-tests'; +import { genCalendarTests } from './calendar-tests'; import { genFacetColumnTests, genFacetRowTests, genFacetColRowTests, genFacetSmallTests, genFacetWrapTests, genFacetClipTests, genFacetOverflowedColTests, genFacetOverflowedColRowTests, genFacetOverflowedRowTests, genFacetDenseLineTests } from './facet-tests'; import { genOverflowTests, genElasticityTests } from './stress-tests'; import { genGasPressureTests } from './gas-pressure-tests'; @@ -152,6 +154,7 @@ export const TEST_GENERATORS: Record TestCase[]> = { 'Grouped Bar Chart': genGroupedBarTests, 'Histogram': genHistogramTests, 'Heatmap': genHeatmapTests, + 'Calendar Heatmap': genCalendarTests, 'Line Chart': () => [...genLineTests(), galleryFacetLineExample()], 'Sparkline': genSparklineTests, 'Bump Chart': genBumpChartTests, @@ -273,4 +276,3 @@ export const TEST_GENERATORS: Record TestCase[]> = { 'Omni: Heatmap': genOmniVizHeatmapTests, 'Omni: Sunburst': genOmniVizSunburstTests, }; - diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index eb5a1c70..4ef2a977 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -66,7 +66,7 @@ import { computeLayout, computeChannelBudgets, computeMinSubplotDimensions, deri import { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec'; import { normalizeStaticSeries } from '../core/static-series'; import { normalizeChartProperties } from '../core/normalize-properties'; -import { groundTheme, resolveChartDefaults, resolveCompileDefaults } from '../core/theme/ground'; +import { groundTheme, resolveChartDefaults, resolveCompileDefaults, resolveGeometry } from '../core/theme/ground'; import { resolveThemeSpec } from '../core/theme/presets'; import { realizeThemeVegaLite, realizeValueLabelsVegaLite, collectMarkTypes, collectPositional } from './theme'; @@ -109,6 +109,19 @@ const escapeVlFieldName = (name: string): string => * }); * ``` */ +/** The headline and deck as one string, whichever shape the title took. */ +function headlineText(title: any): string | undefined { + if (!title) return undefined; + const parts: string[] = []; + const push = (v: any) => { + if (typeof v === 'string') parts.push(v); + else if (Array.isArray(v)) v.forEach(push); + }; + if (typeof title === 'string') push(title); + else { push(title.text); push(title.subtitle); } + return parts.length ? parts.join(' ') : undefined; +} + export function assembleVegaLite(input: ChartAssemblyInput): any { const chartType = input.chart_spec.chartType; const semanticTypes = input.semantic_types ?? {}; @@ -167,6 +180,13 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { if (choice) chartProperties.showTextLabels = choice === 'on'; } + // Geometry settles before anything is measured: whether a line carries dots + // and how much of its step a bar fills change what the layout has to fit, + // not just how the result is painted. + const { geometry: chartGeometry, report: geometryReport } = resolveGeometry( + themeSpec, chartType, chartTemplate.geometryKinds, + ); + // ═══════════════════════════════════════════════════════════════════════ // PRE-PHASE: Static Series Normalization // ═══════════════════════════════════════════════════════════════════════ @@ -373,6 +393,8 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { // Merge paramOverrides into effective options const effectiveOptions: AssembleOptions = { + // Vega-Lite's native width:{step} preserves the authored category pitch. + bandStepFit: 0, // Vega-Lite native font defaults (labels 10, titles 11). baseLabelFontSize: 10, baseTitleFontSize: 11, @@ -389,18 +411,33 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { // But a band step is a statement about bar thickness, and where *both* axes // are banded there are no bars: the marks are cells, and a cell's size is // fixed by the two counts and the room they share. A house that asks for - // 80px categories would print a grid of stripes. That one the layout keeps. + // A bar-sized category step would print a grid of stripes. That one the + // layout keeps. const houseBandStep = themeSpec?.layout?.bandStep; + const houseBandStepFit = themeSpec?.layout?.bandStepFit; const cellGrid = declaration.axisFlags?.x?.banded === true && declaration.axisFlags?.y?.banded === true; // A template may state a floor its read cannot go below (a slopegraph needs // its two columns spread wide however compact the house). The house sets // the band step, but not below that floor. const minBandStep = (declaration.paramOverrides as AssembleOptions | undefined)?.minBandStep; + if (houseBandStepFit != null && options.bandStepFit == null && !cellGrid) { + effectiveOptions.bandStepFit = Math.max(0, Math.min(1, houseBandStepFit)); + chartDefaultsReport.push({ + stage: 'ground', + path: 'layout.bandStepFit', + message: `the house blends ${Math.round(effectiveOptions.bandStepFit * 100)}% toward the available span per category`, + }); + } + const fitsToRoom = (effectiveOptions.bandStepFit ?? 0) > 0; if (houseBandStep && options.defaultBandSize == null && !cellGrid) { const step = minBandStep ? Math.max(houseBandStep, minBandStep) : houseBandStep; effectiveOptions.defaultBandSize = step; - effectiveOptions.maxBandSize = Math.max(step, effectiveOptions.maxBandSize ?? 0); + // A house that also fits its sparse bands to the room has to be able to + // pass its own base pitch; the layout's sparse ceiling holds it in. + if (!fitsToRoom) { + effectiveOptions.maxBandSize = Math.max(step, effectiveOptions.maxBandSize ?? 0); + } chartDefaultsReport.push({ stage: 'ground', path: 'layout.bandStep', @@ -442,6 +479,26 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { const caps = deriveStretchCaps(baseSize, sizeCeiling, effectiveOptions); effectiveOptions.maxStretchX = caps.maxStretchX; effectiveOptions.maxStretchY = caps.maxStretchY; + // The fit aims at the room the *plot* gets, not the whole box: a chart that + // spends its margins on a value gutter and a key has that much less to hand + // its bands. The reserve is a soft margin, not a guarantee. + const keyPlacement: string[] = themeSpec?.legend?.placement ?? []; + // A key is reserved on the house's placement alone. Whether one appears is + // a fact about the compiled spec, and templates that build their own from a + // working column (a waterfall's step types) never show a bound channel here. + const keyed = Boolean(channelSemantics.color?.field || channelSemantics.group?.field) + || chartTemplate.channels?.includes('color') === true; + const titled = Boolean(input.chart_spec.title?.trim() || input.chart_spec.subtitle?.trim()); + const reserveW = (channelSemantics.y?.field ? FURNITURE.valueGutter : 0) + + (keyed && keyPlacement.some((p) => SIDE_KEY.has(p)) ? FURNITURE.sideKey : 0); + const reserveH = (channelSemantics.x?.field ? FURNITURE.tickGutter : 0) + + (titled ? FURNITURE.titleBlock : 0) + + (keyed && keyPlacement.some((p) => STACKED_KEY.has(p)) ? FURNITURE.stackedKey : 0); + const ceilingW = sizeCeiling?.width ?? baseSize.width; + const ceilingH = sizeCeiling?.height ?? baseSize.height; + // Furniture may take a chart's margins, never most of its plot. + effectiveOptions.bandStepFitCapacityX = Math.max(ceilingW * 0.6, ceilingW - reserveW); + effectiveOptions.bandStepFitCapacityY = Math.max(ceilingH * 0.6, ceilingH - reserveH); effectiveOptions.facetColumns = resolveFacetColumnsOption(input.chart_spec.chartProperties); const facetFixW = effectiveOptions.facetFixedPadding.width; const facetFixH = effectiveOptions.facetFixedPadding.height; @@ -586,6 +643,7 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { semanticTypes, chartType, assembleOptions: effectiveOptions, + geometry: chartGeometry, }; chartTemplate.instantiate(vgObj, instantiateContext); @@ -782,8 +840,10 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { stacked: stacked === 'normalize' ? 'normalize' : Boolean(stacked), partToWhole: markTypes.includes('arc'), titled: Boolean(vgObj.title), + headline: headlineText(vgObj.title), hostSurface: (input.options as any)?.background, valueLabels: resolveValueLabelChoice(chartProperties), + geometryKinds: chartTemplate.geometryKinds, }); let themeDecisions: any; @@ -791,7 +851,7 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { const realizeReport = realizeThemeVegaLite(vgObj, design, values); themeDecisions = { ...design, - report: [...themePresets.report, ...chartDefaultsReport, ...design.report, ...realizeReport], + report: [...themePresets.report, ...chartDefaultsReport, ...geometryReport, ...design.report, ...realizeReport], }; } else { realizeValueLabelsVegaLite(vgObj, design, values); @@ -932,6 +992,31 @@ function templateOwnsValueLabels(template: ChartTemplateDef): boolean { return template.ownsValueLabels === true; } +/** A key charges the dimension it sits along, and only that one. */ +const SIDE_KEY = new Set(['right', 'left', 'seriesEnd']); +const STACKED_KEY = new Set(['top', 'bottom']); + +/** + * A soft margin for what a chart draws *around* its plot. + * + * Read off presence rather than measured: is there a title block, where does + * the key sit, is the axis labelled at all. Wrong in the small — a long name + * costs more than a short one, a facet pays per panel — but it stops the fit + * aiming a banded axis at room the axes and key have already spent. + */ +const FURNITURE = { + /** Tick labels down the side of the plot. */ + valueGutter: 70, + /** One line of tick labels under it. */ + tickGutter: 35, + /** Headline plus deck. */ + titleBlock: 30, + /** A key in the side margin. */ + sideKey: 100, + /** A key above or below. */ + stackedKey: 30, +}; + function resolveValueLabelChoice( chartProperties: Record | undefined, ): 'on' | 'off' | undefined { diff --git a/packages/flint-js/src/vegalite/instantiate-spec.ts b/packages/flint-js/src/vegalite/instantiate-spec.ts index a755b87d..d89ce135 100644 --- a/packages/flint-js/src/vegalite/instantiate-spec.ts +++ b/packages/flint-js/src/vegalite/instantiate-spec.ts @@ -212,6 +212,7 @@ export function vlApplyLayoutToSpec( new Date(maxVal + pad).toISOString(), ]; } else { + enc.scale.zero = false; enc.scale.domain = [minVal - pad, maxVal + pad]; } } diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts index 38721a11..57518511 100644 --- a/packages/flint-js/src/vegalite/templates/area.ts +++ b/packages/flint-js/src/vegalite/templates/area.ts @@ -117,6 +117,7 @@ export const areaChartDef: ChartTemplateDef = { template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], markCognitiveChannel: 'area', + geometryKinds: ['area', 'line', 'point'], declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, }), diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts index c89a64aa..f7c8467e 100644 --- a/packages/flint-js/src/vegalite/templates/bar.ts +++ b/packages/flint-js/src/vegalite/templates/bar.ts @@ -77,6 +77,7 @@ export const barChartDef: ChartTemplateDef = { template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], markCognitiveChannel: 'length', + geometryKinds: ['band'], declareLayoutMode: (cs, table) => { const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' }); return { diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts new file mode 100644 index 00000000..4fa11c46 --- /dev/null +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Vega-Lite Calendar Heatmap template. + * + * The ECharts backend has a first-class calendar coordinate system; Vega-Lite + * has none, so two UTC calendar fields are derived from the supplied date: + * x → Monday at the start of the observation's week + * y → Monday-first weekday name + * Using one UTC definition for both fields prevents ISO dates from drifting a + * weekday in negative-offset hosts and keeps every column a contiguous + * Monday–Sunday week. + * + * Encoding: + * x (temporal) → the date of each cell + * color (quantitative) → the cell value (defaults to a count of 1) + */ + +import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; + +/** Weekday row order, Monday-first — mirrors the ECharts template's dayLabel.firstDay = 1. */ +const WEEKDAY_ORDER = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +/** + * Sequential schemes. Named Vega-Lite schemes pass through as `scale.scheme`; + * 'github' has no built-in Vega-Lite equivalent, so it resolves to an explicit + * `scale.range` (the same low→high ramp the ECharts template uses). + */ +const GITHUB_RANGE = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39']; +const VL_SCHEMES = new Set(['viridis', 'blues', 'greens', 'reds', 'oranges', 'purples']); +const COUNT_FIELD = '__flintCalendarCount'; +const DATE_FIELD = '__flintCalendarDate'; +const WEEK_FIELD = '__flintCalendarWeek'; +const WEEKDAY_FIELD = '__flintCalendarWeekday'; + +function calendarDate(raw: unknown): Date | undefined { + if (raw instanceof Date) { + return Number.isFinite(raw.getTime()) ? new Date(raw.getTime()) : undefined; + } + const text = typeof raw === 'string' ? raw.trim() : undefined; + if (text && /^\d{4}-\d{2}-\d{2}$/.test(text)) { + const [year, month, date] = text.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, date)); + } + if (text + && /^\d{4}-\d{2}-\d{2}[T ]/.test(text) + && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(text)) { + const date = new Date(`${text.replace(' ', 'T')}Z`); + return Number.isFinite(date.getTime()) ? date : undefined; + } + if (typeof raw !== 'string' && typeof raw !== 'number') return undefined; + const date = new Date(text ?? raw); + return Number.isFinite(date.getTime()) ? date : undefined; +} + +function calendarWeekDomain(table: any[], field: string): number[] | undefined { + const dates = table.flatMap(row => { + const date = calendarDate(row?.[field]); + return date ? [date] : []; + }); + if (!dates.length) return undefined; + dates.sort((a, b) => a.getTime() - b.getTime()); + + const first = dates[0]; + const last = dates[dates.length - 1]; + const start = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), 1)); + start.setUTCDate(start.getUTCDate() - ((start.getUTCDay() + 6) % 7)); + const end = new Date(Date.UTC(last.getUTCFullYear(), last.getUTCMonth() + 1, 0)); + end.setUTCDate(end.getUTCDate() - ((end.getUTCDay() + 6) % 7)); + + const domain: number[] = []; + for (const week = new Date(start); week <= end; week.setUTCDate(week.getUTCDate() + 7)) { + domain.push(week.getTime()); + } + return domain; +} + +export const vlCalendarHeatmapDef: ChartTemplateDef = { + chart: 'Calendar Heatmap', + template: { mark: { type: 'rect', cornerRadius: 2 }, encoding: {} }, + channels: ['x', 'color'], + markCognitiveChannel: 'color', + declareLayoutMode: () => ({ + // Both axes are ordinal bands (week columns × weekday rows); square-ish + // cells read as a calendar rather than a stretched grid. + axisFlags: { x: { banded: true }, y: { banded: true } }, + }), + instantiate: (spec, ctx) => { + const dateField = ctx.channelSemantics.x?.field; + const valueField = ctx.channelSemantics.color?.field; + if (!dateField) return; + // A calendar ends mid-week as often as not, and a frame around the plot + // draws a box around the days the last week does not have. The cells + // are the grid. + spec._hideViewStroke = true; + const weekDomain = calendarWeekDomain(ctx.table, dateField); + spec.data = { + values: ctx.table.map(row => ({ + ...row, + [DATE_FIELD]: calendarDate(row?.[dateField])?.getTime() ?? null, + })), + }; + + const date = `toDate(datum[${JSON.stringify(DATE_FIELD)}])`; + const utcWeek = `utc(utcyear(${date}),utcmonth(${date}),utcdate(${date})-((utcday(${date})+6)%7))`; + spec.transform = [ + ...(spec.transform ?? []), + { calculate: utcWeek, as: WEEK_FIELD }, + { + calculate: `['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][utcday(${date})]`, + as: WEEKDAY_FIELD, + }, + ...(!valueField ? [{ calculate: '1', as: COUNT_FIELD }] : []), + ]; + + const encScheme = ctx.encodings?.color?.scheme; + const scheme = encScheme && encScheme !== 'default' ? encScheme : 'viridis'; + const cornerRadius = ctx.chartProperties?.cornerRadius; + if (typeof cornerRadius === 'number') { + spec.mark = { ...(typeof spec.mark === 'object' ? spec.mark : { type: 'rect' }), cornerRadius }; + } + const colorScale = + scheme === 'github' + // Quantile scale snaps counts into the 5 canonical GitHub buckets + // (equal-count bins → discrete levels), rather than a smooth ramp. + ? { type: 'quantile' as const, range: GITHUB_RANGE } + : { scheme: VL_SCHEMES.has(scheme) ? scheme : 'viridis' }; + + spec.encoding = { + // One ordinal column per calendar week; month initials label the axis. + x: { + field: WEEK_FIELD, + type: 'ordinal', + title: null, + ...(weekDomain ? { scale: { domain: weekDomain } } : {}), + axis: { + // `yearweek` has one tick per week. Print the month only + // on its first weekly boundary instead of repeating "Jan" + // under every January column. + labelExpr: "utcdate(datum.value) <= 7 ? utcFormat(datum.value, '%b') : ''", + labelAngle: 0, + labelOverlap: false, + tickBand: 'extent', + domain: false, + ticks: false, + }, + }, + // Sun–Sat rows, Monday-first to match the ECharts calendar. + y: { + field: WEEKDAY_FIELD, + type: 'ordinal', + title: null, + sort: WEEKDAY_ORDER, + axis: { domain: false, ticks: false }, + }, + // Sum collapses multiple rows sharing a calendar day into one cell. + color: { + ...(valueField + ? { field: valueField, aggregate: 'sum' } + : { field: COUNT_FIELD, aggregate: 'sum' }), + type: 'quantitative', + legend: { title: null }, + scale: colorScale, + }, + }; + }, + encodingActions: [ + { + key: 'colorScheme', + label: 'Scheme', + isApplicable: (ctx) => !!ctx.encodings.color?.field, + dependencies: ['color'], + control: { + type: 'discrete', + options: [ + { value: undefined, label: 'Default (Viridis)' }, + { value: 'viridis', label: 'Viridis' }, + { value: 'github', label: 'GitHub' }, + { value: 'blues', label: 'Blues' }, + { value: 'greens', label: 'Greens' }, + { value: 'reds', label: 'Reds' }, + { value: 'oranges', label: 'Oranges' }, + { value: 'purples', label: 'Purples' }, + ], + }, + get: (enc) => enc.color?.scheme, + set: (enc, value) => ({ ...enc, color: { ...enc.color, scheme: value } }), + }, + ] as EncodingActionDef[], + properties: [ + { key: 'cornerRadius', label: 'Corners', type: 'continuous', min: 0, max: 8, step: 1, defaultValue: 2 }, + ] as ChartPropertyDef[], +}; diff --git a/packages/flint-js/src/vegalite/templates/index.ts b/packages/flint-js/src/vegalite/templates/index.ts index d364e567..5b5072e6 100644 --- a/packages/flint-js/src/vegalite/templates/index.ts +++ b/packages/flint-js/src/vegalite/templates/index.ts @@ -37,6 +37,7 @@ import { radarChartDef } from './radar'; import { roseChartDef } from './rose'; import { mapDef, choroplethDef } from './map'; import { kpiCardDef } from './kpi-card'; +import { vlCalendarHeatmapDef } from './calendar'; /** * Cross-cutting properties injected into every template that supports @@ -288,7 +289,7 @@ export const vlTemplateDefs: { [key: string]: ChartTemplateDef[] } = Object.from "Distributions": [histogramDef, densityPlotDef, ecdfPlotDef, violinPlotDef, boxplotDef, pyramidChartDef, candlestickChartDef], "Lines & Areas": [lineChartDef, sparklineDef, bumpChartDef, slopeChartDef, areaChartDef, streamgraphDef, rangeAreaChartDef], "Circular": [pieChartDef, donutChartDef, roseChartDef, radarChartDef], - "Tables & Maps": [heatmapDef, barTableDef, kpiCardDef, mapDef, choroplethDef], + "Tables & Maps": [heatmapDef, vlCalendarHeatmapDef, barTableDef, kpiCardDef, mapDef, choroplethDef], }).map(([category, defs]) => [category, defs.map(withInjectedProperties)]), ); diff --git a/packages/flint-js/src/vegalite/templates/line.ts b/packages/flint-js/src/vegalite/templates/line.ts index c61a34ca..d8597621 100644 --- a/packages/flint-js/src/vegalite/templates/line.ts +++ b/packages/flint-js/src/vegalite/templates/line.ts @@ -118,6 +118,7 @@ export const lineChartDef: ChartTemplateDef = { template: { mark: "line", encoding: {} }, channels: ["x", "y", "color", "strokeDash", "detail", "opacity", "column", "row"], markCognitiveChannel: 'position', + geometryKinds: ['line', 'point'], declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, }), diff --git a/packages/flint-js/src/vegalite/templates/pie.ts b/packages/flint-js/src/vegalite/templates/pie.ts index d611efe7..02616fb8 100644 --- a/packages/flint-js/src/vegalite/templates/pie.ts +++ b/packages/flint-js/src/vegalite/templates/pie.ts @@ -10,6 +10,7 @@ export const pieChartDef: ChartTemplateDef = { template: { mark: "arc", encoding: {} }, channels: ["size", "color", "column", "row"], markCognitiveChannel: 'area', + geometryKinds: ['arc'], instantiate: (spec, ctx) => { // Remap abstract channels to VL channels: // "size" → VL "theta" (angular extent of each slice) diff --git a/packages/flint-js/src/vegalite/templates/radar.ts b/packages/flint-js/src/vegalite/templates/radar.ts index 31682cbf..0e7ff79c 100644 --- a/packages/flint-js/src/vegalite/templates/radar.ts +++ b/packages/flint-js/src/vegalite/templates/radar.ts @@ -173,6 +173,7 @@ function buildRadarLayers( // Spokes layers.push({ + name: "radar-grid-spokes", data: { values: gridData.filter(d => d.__type === "spoke") }, mark: { type: "rule", stroke: "#ddd", strokeWidth: 0.8 }, encoding: { @@ -184,6 +185,7 @@ function buildRadarLayers( // Rings layers.push({ + name: "radar-grid-rings", data: { values: gridData.filter(d => d.__type === "ring") }, mark: { type: "rule", stroke: "#e0e0e0", strokeWidth: 0.6 }, encoding: { @@ -246,6 +248,7 @@ function buildRadarLayers( // Data points const pointLayer: any = { + name: "radar-secondary-vertices", data: { values: finalData }, mark: { type: "point", filled: true, size: 25 }, encoding: { diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts index 9cac8d9b..93b33dcb 100644 --- a/packages/flint-js/src/vegalite/templates/waterfall.ts +++ b/packages/flint-js/src/vegalite/templates/waterfall.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { resolveDiscreteType } from '../../core/axis-detection'; import { resolveTotalsMode } from '../../chart-types/waterfall'; /** @@ -21,8 +22,14 @@ export const waterfallChartDef: ChartTemplateDef = { channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'length', ownsValueLabels: true, - declareLayoutMode: () => ({ + // The steps are drawn on a band scale whatever the column holds — a month + // is a step here, not a date. Saying so keeps the layout's category sizing + // in step with the ordinal encoding the template writes below. + declareLayoutMode: (cs, table) => ({ axisFlags: { x: { banded: true } }, + resolvedTypes: { + x: resolveDiscreteType(cs.x?.type ?? 'nominal', cs.x?.field, table ?? []), + }, }), instantiate: (spec, ctx) => { const { x, y, color, column, row } = ctx.resolvedEncodings; @@ -131,6 +138,7 @@ export const waterfallChartDef: ChartTemplateDef = { // internal bindings: a null on a primary channel resolves the merged // axis title to nothing, blanking the axis for every layer. const xEnc = { + ...x, field: xField, type: "ordinal" as const, sort: null, @@ -139,7 +147,6 @@ export const waterfallChartDef: ChartTemplateDef = { // the axis title ("Month, __wf_lead"). Pinning the resolved title // here keeps the internal column off the axis and still honours a // caller-supplied label, falling back to the field name. - axis: { labelAngle: -45 }, title: xTitle, }; diff --git a/packages/flint-js/src/vegalite/theme.ts b/packages/flint-js/src/vegalite/theme.ts index cf2eadbb..21a32be1 100644 --- a/packages/flint-js/src/vegalite/theme.ts +++ b/packages/flint-js/src/vegalite/theme.ts @@ -15,7 +15,7 @@ */ import type { DesignDecisions, ThemeReport } from '../core/theme/types.js'; -import { contrastingInk, parseColor, luminance, toHex } from '../core/theme/presence.js'; +import { contrastingInk, parseColor, luminance, mixHex, toHex } from '../core/theme/presence.js'; import { CONTINUOUS_BAR_STEP_FILL, coverageSizedMarks } from './templates/utils.js'; import { LOCAL_DODGE_LANE_FILL } from './templates/bar.js'; import { CANVAS_FURNITURE_KEY, readCanvasFurniture, type CanvasFurnitureItem } from './canvas-furniture.js'; @@ -284,6 +284,7 @@ export function realizeThemeVegaLite(spec: any, d: DesignDecisions, table: any[] applyPointEmphasis(spec, d, say); applyPrintedUnits(spec, d, say); applyStatistics(spec, d, table, say); + fitTitle(spec, d, table, say); const wrapped = applyFurniture(spec, d, table, say); return wrapped ? report : report; @@ -302,6 +303,10 @@ export function realizeValueLabelsVegaLite(spec: any, d: DesignDecisions, table: const report: ThemeReport[] = []; const say = (path: string, message: string) => report.push({ stage: 'realize', path, message }); applyDataLabels(spec, d, table, say); + // Staying inside the size the caller asked for is the same kind of concern: + // Vega-Lite grows the canvas to whatever one unbroken line of title needs, + // and a long deck took a 420px chart out to 1,249px with no house in sight. + fitTitle(spec, d, table, say); return report; } @@ -348,6 +353,168 @@ function applyTypography(config: any, d: DesignDecisions): void { if (d.title.anchor !== 'middle') config.title.frame = 'bounds'; } +// --------------------------------------------------------------------------- +// Title fitting +// --------------------------------------------------------------------------- + +/** + * Roughly how wide a glyph runs, as a fraction of the font size. + * + * Tuned for a browser, where Vega measures text with the canvas API. Node has + * no canvas backend here, so Vega falls back to an approximation that runs + * ~45% wider — measurements taken from a headless render read high and are not + * grounds for raising this. + */ +const HEADLINE_CHAR_RATIO = 0.55; + +/** How far past the block a headline may run before anything is done about it. */ +const STRETCH_TOLERANCE = 1.18; + +/** How far the headline may be set down before it is broken instead. */ +const MIN_HEADLINE_SCALE = 0.82; + +const CJK_RE = /[\u3000-\u303F\u3400-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]/; + +/** Width of a string in pixels, counting a CJK glyph as a full em. */ +function textPx(s: string, fontSize: number): number { + let em = 0; + for (const ch of s) em += CJK_RE.test(ch) ? 1 : HEADLINE_CHAR_RATIO; + return em * fontSize; +} + +/** + * Break the headline into `count` lines of even length. + * + * Filling each line to the brim and letting the remainder fall through is what + * leaves a headline ending in a single orphaned word. Aiming at the average + * line instead spreads the text over the lines it needs. + */ +function balancedLines(text: string, count: number, fontSize: number): string[] { + const words = text.split(/\s+/).filter(Boolean); + if (words.length <= 1) return [text]; + const target = textPx(text, fontSize) / count; + const lines: string[] = []; + let line = ''; + for (const word of words) { + const candidate = line ? `${line} ${word}` : word; + // Break once the line is closer to the target with this word left off. + if (line && lines.length < count - 1 + && textPx(candidate, fontSize) - target > target - textPx(line, fontSize)) { + lines.push(line); + line = word; + } else { + line = candidate; + } + } + if (line) lines.push(line); + return lines; +} + +/** + * Fit a headline that runs wider than the block it sits over. + * + * Vega-Lite measures the title as one unbroken run and grows the *canvas* to + * fit it, so a long headline quietly widens the graphic past the size the + * caller asked for and leaves the plot stranded at one end. + * + * Three answers, cheapest first: a headline that only just overhangs is left + * alone, because a graphic a little wider than its plot is normal and a break + * there would be gratuitous; one that overhangs more is set down a size; only + * a headline that still does not fit at the smaller size is broken, and then + * over even lines rather than one full line and an orphan. Broken lines are + * taken out of the plot rather than added to the canvas. + */ +function fitTitle(spec: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): void { + // A title spans the whole graphic, not just the plot rectangle: the axis + // gutter under it is the title's to use. Measuring against the plot alone + // broke a headline that had 150px of room to spare beside the row labels. + const plot = blockWidth(spec, table); + const block = Math.max(plot ?? 0, d.layout.canvasWidth ?? 0); + if (!block || !Number.isFinite(block)) return; + fitHeadline(spec, d, block, say); + fitDeck(spec, d, block, say); +} + +/** + * Break a deck that runs wider than the block it sits under. + * + * The headline above it is fitted; the deck was not, so a long one widened the + * graphic on its own even after the headline had been brought to heel. It is + * only ever broken, never set smaller: the deck is already the quietest line in + * the block, and taking it down again would cost legibility to buy width. + */ +function fitDeck(spec: any, d: DesignDecisions, block: number, say: (p: string, m: string) => void): void { + const title = spec.title; + if (!title || typeof title !== 'object') return; + const current = title.subtitle; + const text = Array.isArray(current) ? current.join(' ') : current; + if (typeof text !== 'string' || !text.trim()) return; + + const size = d.title.deck.fontSize ?? 11; + const budget = block * STRETCH_TOLERANCE; + if (textPx(text, size) <= budget) return; + + // Break to the block itself, not to the tolerance. The tolerance buys a + // headline the right to overhang a little, which reads as deliberate; a + // deck given the same licence just runs past the plot on every line. + const count = Math.max(2, Math.ceil(textPx(text, size) / block)); + const lines = balancedLines(text, count, size); + if (lines.length < 2) return; + + spec.title = { ...spec.title, subtitle: lines }; + takeFromPlotHeight(spec, Math.round((lines.length - 1) * size * 1.3)); + say('title.subtitle', + `the deck overhangs the ${Math.round(block)}px block, so it breaks over ${lines.length} even lines` + + ' — the height comes out of the plot, not out of the canvas the caller asked for'); +} + +function fitHeadline(spec: any, d: DesignDecisions, block: number, say: (p: string, m: string) => void): void { + const title = spec.title; + const text = typeof title === 'string' ? title : title?.text; + if (typeof text !== 'string' || !text.trim()) return; + + const budget = block * STRETCH_TOLERANCE; + const size = d.title.headline.fontSize ?? 14; + if (textPx(text, size) <= budget) return; + + const setTitle = (patch: Record) => { + spec.title = { ...(typeof title === 'string' ? { text } : title), ...patch }; + }; + + const smaller = Math.max(Math.round(size * MIN_HEADLINE_SCALE), (d.title.deck.fontSize ?? 11) + 1); + if (smaller < size && textPx(text, smaller) <= budget) { + setTitle({ fontSize: smaller }); + say('title', `the headline overhangs the ${Math.round(block)}px block, so it is set at ${smaller}px rather than broken`); + return; + } + + const count = Math.max(2, Math.ceil(textPx(text, smaller) / budget)); + const lines = balancedLines(text, count, smaller); + if (lines.length < 2) return; + setTitle({ text: lines, ...(smaller < size ? { fontSize: smaller } : {}) }); + takeFromPlotHeight(spec, Math.round((lines.length - 1) * smaller * 1.3)); + say( + 'title', + `the headline does not fit the ${Math.round(block)}px block even at ${smaller}px,` + + ` so it breaks over ${lines.length} even lines — the height comes out of the plot,` + + ' not out of the canvas the caller asked for', + ); +} + +/** The shortest a plot may be squeezed to make room for a taller headline. */ +const MIN_PLOT_HEIGHT = 80; + +/** Take pixels out of the plotting rectangle, wherever its height is stated. */ +function takeFromPlotHeight(spec: any, px: number): void { + const view = spec.config?.view; + if (view && typeof view.continuousHeight === 'number') { + view.continuousHeight = Math.max(MIN_PLOT_HEIGHT, view.continuousHeight - px); + } + walk(spec, (node) => { + if (typeof node.height === 'number') node.height = Math.max(MIN_PLOT_HEIGHT, node.height - px); + }); +} + // --------------------------------------------------------------------------- // Axes // --------------------------------------------------------------------------- @@ -410,6 +577,7 @@ function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say titleFontSize: axis.title.fontSize, titleColor: axis.title.color, titleFontWeight: axis.title.fontWeight ?? 'normal', + ...(axis.title.gap != null ? { titlePadding: axis.title.gap } : {}), }; if (axis.grid.show && twoPositionLineAxis(spec, channel, table)) { themed.grid = false; @@ -418,10 +586,36 @@ function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say 'the line has exactly two banded positions — endpoint guides would either merge with a plot boundary or leave one column looking singled out, so both stand down'); } if (axis.grid.dash) themed.gridDash = axis.grid.dash; + // A grid stops at the plot edge, and where the marks reach that edge — + // a histogram's last bar — the line is flush with the bar and its + // number is stranded off to the side with nothing leading to it. The + // house runs its rules on past the marks to end under the label they + // belong to, so the grid is carried over the gutter as a tick in grid + // ink. A house that draws its own ticks already has a mark there. + if (themed.grid && axis.role === 'measure' && !axis.ticks.show + && axis.label.show !== false) { + const overshoot = Math.max(4, Math.round((axis.label.padding ?? 4) * 0.75)); + themed.ticks = true; + themed.tickColor = axis.grid.color; + themed.tickWidth = axis.grid.width; + themed.tickSize = overshoot; + say(`axes.${channel}.grid`, + `the grid runs ${overshoot}px past the plot to end under its own number — flush with the last mark it leads nowhere`); + } if (axis.ticks.offset) themed.tickOffset = axis.ticks.offset; if (axis.label.show === false) themed.labels = false; if (axis.label.limit != null) themed.labelLimit = axis.label.limit; - if (axis.label.angle != null) themed.labelAngle = axis.label.angle; + // Whether a house wants its names straight is a style matter; whether + // they still read as separate names is not. A straight angle that would + // print `2025-012025-02` is refused and the turn kept. + if (axis.label.angle != null) { + if (labelsFitStraight(spec, channel, axis, d, table)) { + themed.labelAngle = axis.label.angle; + } else { + say(`axes.${channel}.label.angle`, + `the names are wider than their bands — laid straight they would run into each other, so they stay turned`); + } + } if (axis.tickCount != null) themed.tickCount = axis.tickCount; // Vega drops a tick label only once its box *overlaps* its neighbour's, @@ -457,6 +651,15 @@ function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say if (axis.label.angle == null && existing.labelAngle != null) { config[key].labelAngle = existing.labelAngle; } + // A refused straight angle leaves the axis with no angle at all, and + // Vega-Lite's own default is not a fit decision. Turn it the way the + // layout turns labels that will not fit. + if (axis.label.angle != null && themed.labelAngle === undefined + && config[key].labelAngle == null) { + config[key].labelAngle = -45; + config[key].labelAlign = 'right'; + config[key].labelBaseline = 'top'; + } // Encoding-level axis objects outrank config, so anything a template // left behind in the keys this file owns has to go. @@ -464,11 +667,15 @@ function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say let saidTicks = false; let saidGutter = false; let saidUnit = false; - let flatTitle = false; + const defaultOrient = channel === 'x' ? 'bottom' : 'left'; + // One flat title per side: a left and a right ruler caption their own + // values and never land on each other. + const flatTitleSides = new Set(); walk(spec, (node) => { const enc = node.encoding?.[channel]; if (!enc || enc.axis === null) return; const ax = enc.axis; + let unitNamedInTitle = false; if (ax && typeof ax === 'object') { // A few pixels of label padding is styling and this file owns // it. A hundred is not padding, it is a *column*: the template @@ -505,28 +712,62 @@ function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say } } } + // The unit is written once. Where the title already carries it + // — "CO2 (ppm)" — a unit on every tick states it a second time + // down a ruler of "450 ppm", "400 ppm". + const titleNow = enc.axis?.title ?? titleOf(enc); + if (axis.unit && typeof titleNow === 'string' + && titleNow.includes(axis.unit.text)) { + unitNamedInTitle = true; + } // A title lying flat above its own axis reads as a label, not // as a caption turned on its side. Only the vertical axis has // anything to turn. const placement = axis.title.placement; - if (channel === 'y' && !flatTitle + // The title captions the column of values under it, so it + // hangs off the side its own ticks are on — a title at the + // plot's left edge over a right-seated ruler names the wrong + // column. The house's orient wins where it has one; otherwise + // the template's own seat (a second measure on the right) + // stands. + const side = axis.orient !== defaultOrient + ? axis.orient + : (enc.axis?.orient ?? defaultOrient); + if (channel === 'y' && !flatTitleSides.has(side) && (placement === 'flatAboveAxis' || placement === 'inline')) { + const rightSeated = side === 'right'; + // The title clears the topmost value instead of sitting on + // it, so the lift carries a line of the label's own size. + const labelSize = axis.label.fontSize ?? 11; + const gap = axis.title.gap ?? (axis.title.fontSize ?? 11) + 6; + const lift = gap + Math.round(labelSize * 0.75); enc.axis = { ...(enc.axis ?? {}), titleAngle: 0, - titleAlign: 'left', + titleAlign: rightSeated ? 'right' : 'left', titleAnchor: placement === 'inline' ? 'end' : 'start', - titleX: 0, - titleY: -(axis.title.fontSize ?? 11) - 6, + // On a right-hand ruler the renderer has already + // measured how wide the values ran, so leaving `titleX` + // unset seats the title on that outer edge. A guessed + // width cannot: it reads raw data rather than the ticks + // actually drawn, which stood the numbers off the grid + // on one chart and laid them back over the plot on + // another whose field is computed. + ...(rightSeated ? {} : { titleX: 0 }), + titleY: -lift, + titlePadding: 0, titleBaseline: 'bottom', }; - growPadding(spec, 'top', (axis.title.fontSize ?? 11) + 8); - flatTitle = true; + // Both sides' titles sit on one line, so the room above + // the plot is bought once. + if (flatTitleSides.size === 0) { + growPadding(spec, 'top', (axis.title.fontSize ?? 11) + lift); + } + flatTitleSides.add(side); say(`axes.${channel}.title.placement`, - 'the axis title lies flat above the axis, where it reads as a label rather than a caption on its side'); + 'the axis title lies flat at the head of its own ruler, beside the values it names'); } } - const defaultOrient = channel === 'x' ? 'bottom' : 'left'; if (axis.orient !== defaultOrient) { enc.axis = { ...(enc.axis ?? {}), orient: axis.orient }; } @@ -605,11 +846,29 @@ function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say } } + // The plot's edge should fall on a grid line. Vega rounds the + // domain to about ten ticks by default while the axis draws the + // house's count, so the two disagree and the last line stops short + // of the edge — leaving a reading above it with no line to be read + // against. + // + // Only where a grid is actually drawn: rounding outward costs plot + // width, and a house that rules nothing gets nothing back for it — + // on a grid-less scatter it merely pushed the readings into a + // corner, once dragging a `zero: false` axis back to zero. A + // non-linear ruler is left alone too; its ticks are decades, not a + // count. + if (themed.grid && axis.tickCount != null && enc.type === 'quantitative' && !enc.bin + && !NONLINEAR_SCALES.has(enc.scale?.type) + && enc.scale?.domain == null && enc.scale?.nice !== false) { + enc.scale = { ...(enc.scale ?? {}), nice: axis.tickCount }; + } + // The unit has to be written somewhere. A house that drops axis // titles has taken away the usual place, so it says where else — // on every tick, or once at the end of the ruler. const unit = axis.unit; - if (unit && unit.where !== 'never' && axis.label.show !== false + if (unit && unit.where !== 'never' && !unitNamedInTitle && axis.label.show !== false && (enc.type === 'quantitative' || enc.axis?.values != null)) { const tagged = tagWithUnit(enc.axis?.labelExpr, unit.text, unit.where); enc.axis = { ...(enc.axis ?? {}), labelExpr: tagged }; @@ -620,6 +879,10 @@ function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say : `the unit \`${unit.text}\` rides on the ${unit.where === 'firstTick' ? 'first' : unit.where === 'firstAndLast' ? 'first and last' : 'last'} label, where the ruler ends`); saidUnit = true; } + } else if (unit && unit.where !== 'never' && unitNamedInTitle && !saidUnit) { + say(`axes.${channel}.unit`, + `the title already names \`${unit.text}\` — the ruler stays bare rather than repeating the unit down every tick`); + saidUnit = true; } }); } @@ -642,6 +905,43 @@ function bandedAxis(spec: any, channel: 'x' | 'y'): boolean { return banded; } +/** + * Would this axis's names read straight in the band each one actually gets? + * + * Only answerable for a banded axis, where the band is the step the layout + * settled. Anything else is left to the house. + */ +function labelsFitStraight(spec: any, channel: 'x' | 'y', axis: any, d: DesignDecisions, table: any[]): boolean { + if (channel !== 'x') return true; + if ((axis.label.angle ?? 0) !== 0) return true; + if (!bandedAxis(spec, channel)) return true; + + let field: string | undefined; + walk(spec, (node) => { + const enc = node.encoding?.[channel]; + if (!enc?.field || (enc.type !== 'nominal' && enc.type !== 'ordinal')) return; + field ??= enc.field; + }); + if (!field) return true; + + const names = [...new Set(table.map((row) => row?.[field!]).filter((v) => v != null))].map(String); + if (!names.length) return true; + + // A step-sized spec states the band outright; otherwise the categories + // share whatever plot width the layout settled. + const step = typeof spec.width?.step === 'number' ? spec.width.step + : typeof spec.width === 'number' ? spec.width / names.length + : typeof d.layout.xStep === 'number' && d.layout.xStep > 0 ? d.layout.xStep + : typeof d.layout.plotWidth === 'number' ? d.layout.plotWidth / names.length + : undefined; + if (!step) return true; + + const fontSize = typeof axis.label.fontSize === 'number' ? axis.label.fontSize : BASE_LABEL_FONT_SIZE; + const longest = Math.max(...names.map((n) => n.length)); + // Names need air between them, or `2025-012025-02` reads as one number. + return longest * fontSize * 0.62 + 6 <= step; +} + /** A line joining exactly two discrete columns; partial grid thinning would make the pair asymmetric. */ function twoPositionLineAxis(spec: any, channel: 'x' | 'y', table: any[]): boolean { if (!bandedAxis(spec, channel)) return false; @@ -1157,16 +1457,36 @@ function drawsPointCloud(spec: any): boolean { return found; } -function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): void { const config = spec.config; +/** Restate the radar's required coordinate furniture and secondary vertices. */ +function applyRadarMarks(spec: any, d: DesignDecisions): void { + const plot = d.surface.plot ?? d.surface.canvas; + walk(spec, (node) => { + if (node.name === 'radar-grid-spokes' || node.name === 'radar-grid-rings') { + const mark = normalizeMark(node.mark); + const ring = node.name === 'radar-grid-rings'; + mark.stroke = mixHex(plot, d.text.secondary, ring ? 0.18 : 0.24); + mark.strokeWidth = Math.min(1, Number(mark.strokeWidth) || 1); + node.mark = mark; + } + if (node.name === 'radar-secondary-vertices') { + const mark = normalizeMark(node.mark); + mark.size = d.marks.point?.secondarySize ?? 25; + node.mark = mark; + } + }); +} + +function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): void { + const config = spec.config; const m = d.marks; const plotWidth = spec.config?.view?.continuousWidth ?? spec._width ?? 300; const plotHeight = spec.config?.view?.continuousHeight ?? spec._height ?? 300; + applyRadarMarks(spec, d); config.line = { ...(config.line ?? {}), strokeWidth: m.strokeWidth }; config.trail = { ...(config.trail ?? {}), size: m.strokeWidth }; if (m.strokeCap) { config.line.strokeCap = m.strokeCap; config.rule = { ...(config.rule ?? {}), strokeCap: m.strokeCap }; } - if (m.strokeJoin) config.line.strokeJoin = m.strokeJoin; - if (m.interpolate) config.line.interpolate = m.interpolate; + if (m.strokeJoin) config.line.strokeJoin = m.strokeJoin; if (m.interpolate) config.line.interpolate = m.interpolate; if (m.fillOpacity != null) config.area = { ...(config.area ?? {}), fillOpacity: m.fillOpacity }; if (m.cornerRadius != null) { // Round only the value end of a bar so the baseline stays a clean edge @@ -1183,6 +1503,15 @@ function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string // bars wide enough to hold it. Grid cells (heatmaps) are a field, not // shapes to cut out, and are held apart by a tile gap, not an outline. config.arc = { ...(config.arc ?? {}), stroke: m.outline.color, strokeWidth: m.outline.width }; + // An outlined shape has corners of its own, and the join is a property + // of the stroke, not of lines. Left to the renderer's mitre, the two + // radial edges of a thin wedge run past each other into a spike at the + // centre — worst exactly where the house draws the fattest outline. + if (m.strokeJoin) { + for (const family of ['arc', 'bar', 'area', 'point'] as const) { + config[family] = { ...(config[family] ?? {}), strokeJoin: m.strokeJoin }; + } + } } protectDashEncoding(spec, config, m.strokeWidth); @@ -1194,7 +1523,9 @@ function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string if (m.point?.show) { config.line.point = { filled: m.point.filled !== false, - size: m.point.size ?? 24, + // A vertex confirms a path's position; the primary size is the size + // of a dot that *is* the reading. Houses that separate them say so. + size: m.point.vertexSize ?? m.point.size ?? 24, ...(m.outline ? { stroke: m.outline.color, strokeWidth: m.outline.width } : m.point.haloColor @@ -1400,6 +1731,7 @@ function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string spec, config.point?.size ?? config.circle?.size ?? 30, config.point?.strokeWidth ?? m.outline?.width ?? 0, + table, say, ); @@ -1566,6 +1898,21 @@ function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string let bandCh: 'x' | 'y' | undefined; walk(spec, (node) => { const mark = markTypeOf(node.mark); + // A stacked band adjoins its neighbour along its whole length, so it + // is held apart the same way bars are — without the cut, three + // quantities read as one silhouette. + if (mark === 'area') { + const enc = node.encoding ?? {}; + const banded = Boolean(enc.color?.field ?? enc.fill?.field ?? spec.encoding?.color?.field); + if (!banded || isLiteralMark(node)) return; + node.mark = { + ...normalizeMark(node.mark), + stroke: m.separator!.color, + strokeWidth: m.separator!.width, + }; + sawStroke = true; + return; + } if (mark !== 'bar' && mark !== 'rect') return; if (isLiteralMark(node)) return; // A cell in a grid is not a bar in a row: it adjoins on both axes @@ -1657,6 +2004,7 @@ function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string } }); } + } /** @@ -2208,6 +2556,78 @@ function dotOuterDiameter(size: number, strokeWidth: number): number { return 2 * Math.sqrt(size / Math.PI) + strokeWidth; } +/** + * Round a span outward to whole ticks, the way the renderer's own scale would. + * + * This is d3's `nice`, which Vega uses: the step is chosen from the 1/2/5/10 + * family, and both ends are pushed out to a multiple of it until the step + * settles. It is reproduced rather than called because the rounding has to be + * applied to the *data*, before the pixel padding a dot scale carries — and + * `scale.nice` can only be applied after. + */ +const E10 = Math.sqrt(50); +const E5 = Math.sqrt(10); +const E2 = Math.SQRT2; + +function tickIncrement(start: number, stop: number, count: number): number { + const step = (stop - start) / Math.max(1, count); + const power = Math.floor(Math.log10(step)); + const error = step / 10 ** power; + const factor = error >= E10 ? 10 : error >= E5 ? 5 : error >= E2 ? 2 : 1; + return power >= 0 ? factor * 10 ** power : -(10 ** -power) / factor; +} + +function niceSpan(start: number, stop: number, count: number): [number, number] { + let lo = start; + let hi = stop; + let prestep: number | undefined; + // d3 loops until the step settles; a bound keeps a pathological span finite. + for (let i = 0; i < 32; i += 1) { + const step = tickIncrement(lo, hi, count); + if (step === prestep || step === 0 || !Number.isFinite(step)) break; + if (step > 0) { + lo = Math.floor(lo / step) * step; + hi = Math.ceil(hi / step) * step; + } else { + lo = Math.ceil(lo * step) / step; + hi = Math.floor(hi * step) / step; + } + prestep = step; + } + return [lo, hi]; +} + +/** + * The rounded span of a plain numeric field, or nothing when it cannot be read. + * + * A pinned domain narrower than the data would put marks off the chart, so + * anything the table cannot answer for — an aggregate, a bin, a time unit, a + * stack, a field the rows do not carry — returns nothing and the caller leaves + * the scale to the renderer. + */ +function roundedFieldDomain(enc: any, table: any[], count: number): [number, number] | undefined { + if (!Array.isArray(table) || !table.length) return undefined; + if (typeof enc?.field !== 'string') return undefined; + if (enc.aggregate || enc.bin || enc.timeUnit || enc.stack) return undefined; + if (enc.type && enc.type !== 'quantitative') return undefined; + + let lo = Infinity; + let hi = -Infinity; + for (const row of table) { + const v = row?.[enc.field]; + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return undefined; + + const [a, b] = niceSpan(lo, hi, count); + // Rounding only ever pushes outward; anything else means the arithmetic + // disagreed with the data, and a domain inside it would clip. + if (!(a <= lo && b >= hi)) return undefined; + return [a, b]; +} + /** * A dot has a radius; a scale fitted to the data does not know that. * @@ -2226,7 +2646,7 @@ function dotOuterDiameter(size: number, strokeWidth: number): number { * bar, an area, a lollipop's stem — because there the edge is a base, and a * base that has been nudged off zero is worse than a clipped dot. */ -function padScaleForDots(spec: any, size: number, strokeWidth: number, say: (p: string, m: string) => void): void { +function padScaleForDots(spec: any, size: number, strokeWidth: number, table: any[], say: (p: string, m: string) => void): void { const radius = Math.ceil(dotOuterDiameter(size, strokeWidth) / 2); if (radius <= 0) return; // A dumbbell keeps its position channels on the parent and gives the dot @@ -2256,6 +2676,7 @@ function padScaleForDots(spec: any, size: number, strokeWidth: number, say: (p: if (anchored || !dots.length) return; let padded = false; + const pinned: Partial> = {}; for (const declared of dots) { for (const ch of ['x', 'y'] as const) { const enc = declared[ch]; @@ -2264,16 +2685,50 @@ function padScaleForDots(spec: any, size: number, strokeWidth: number, say: (p: // A scale pinned to zero, or to a domain the caller chose, is // saying where its ends are. Padding would move them. if (enc.scale?.zero === true || enc.scale?.domain || enc.scale?.padding != null) continue; - // `nice` rounds the domain outward to the next whole tick, and it - // does that *after* the padding is folded in — so a 4px gap turns - // into a whole extra interval of empty plot and an axis labelled - // past where the data goes. The padding is the breathing room; the - // rounding on top of it is not wanted. - enc.scale = { ...(enc.scale ?? {}), padding: radius, nice: enc.scale?.nice ?? false }; + // The padding is the breathing room that keeps the outermost dot + // inside the axes; rounding on top of it is not wanted here. A dot + // plot has no bar to anchor the eye, so an extra interval of empty + // scale reads as the readings hiding in a corner — and rounding + // outward can drag a `zero: false` ruler back to zero. + // + // Where a grid is drawn, the axis pass asked for the domain to be + // rounded to the house's tick count so the last line lands on the + // plot edge. Vega pads *before* it rounds, so honouring that here + // would round the padded domain and turn a few pixels of clearance + // into a whole extra interval: a 0-100 score came out as -10 to + // 110. The data is rounded instead, and the padding stays the + // sliver it is. Where the data cannot be read off the table — + // an aggregate, a bin, a computed field — the rounding is dropped + // rather than guessed, since a pinned domain that is too narrow + // clips marks off the chart. + const count = typeof enc.scale?.nice === 'number' ? enc.scale.nice : undefined; + const rounded = count != null ? roundedFieldDomain(enc, table, count) : undefined; + if (rounded) pinned[ch] = rounded; + enc.scale = { + ...(enc.scale ?? {}), + padding: radius, + ...(rounded ? { domainMin: rounded[0], domainMax: rounded[1] } : {}), + nice: false, + }; padded = true; } } if (padded) { + // Layers share a scale, and the axis pass wrote its rounding onto every + // encoding of the channel — including the ones that carry no dot. Left + // alone they disagree with the decision above, and the renderer warns + // and picks one. Settle them all the same way. + walk(spec, (node) => { + for (const ch of ['x', 'y'] as const) { + const enc = node.encoding?.[ch]; + if (!enc?.field || typeof enc.scale?.nice !== 'number') continue; + enc.scale = { + ...enc.scale, + ...(pinned[ch] ? { domainMin: pinned[ch]![0], domainMax: pinned[ch]![1] } : {}), + nice: false, + }; + } + }); say('marks.point.size', `the plot is opened by ${radius}px at each end — a dot's own radius — so the outermost reading sits inside the axes instead of half outside them`); } @@ -3119,8 +3574,12 @@ function applyLegend(spec: any, config: any, d: DesignDecisions, table: any[], s // also what seaborn does when one legend carries both: each block keeps // the variable name as a heading, because the heading is the only thing // telling a row of sizes apart from a row of colours. + // A key that keeps its title stands a line taller than one that dropped it. + // Side by side that leaves the row ragged, so the layout below has to know. + let titlesUneven = false; if (!l.title) { let valueKeys = 0; + let baredKeys = 0; walk(spec, (node) => { for (const channel of ['color', 'fill', 'stroke', 'shape', 'size', 'opacity'] as const) { const enc = node.encoding?.[channel]; @@ -3131,8 +3590,10 @@ function applyLegend(spec: any, config: any, d: DesignDecisions, table: any[], s && enc.type === 'quantitative'; if (isValueKey) { valueKeys++; continue; } enc.legend = { ...(enc.legend ?? {}), title: null }; + baredKeys++; } }); + titlesUneven = valueKeys > 0 && baredKeys > 0; if (valueKeys === 0) config.legend.title = null; else { say('legend.title', @@ -3152,24 +3613,13 @@ function applyLegend(spec: any, config: any, d: DesignDecisions, table: any[], s say('legend.offset', `the top key clears the flat axis title by ${clearance}px so its last row does not land on the title`); } - // A top or bottom key is a caption to the whole graphic, so it begins where - // the graphic does — flush with the title down the left edge — not indented - // to the plot rectangle the way Vega-Lite lays it by default. `bounds: - // 'full'` measures the key against the same box the start-anchored title - // uses (axes included), so the two share one left margin; the whitespace an - // indented key leaves between itself and the title, worst when the key - // wraps to several rows, closes up. - if ((l.orient === 'top' || l.orient === 'bottom') - && (l.direction ?? 'horizontal') === 'horizontal') { - config.legend.layout = { - ...(config.legend.layout ?? {}), - [l.orient]: { - anchor: 'start', - bounds: 'full', - ...(config.legend.layout?.[l.orient] ?? {}), - }, - }; - } + // A top or bottom key reads as a caption to the whole graphic, so the house + // would rather it began where the title does than indented to the plot. + // Vega-Lite has no way to say so: `legend.layout` was dropped after v4 and + // is silently discarded, and the only remaining route — `orient: 'none'` + // with an explicit `legendX` — would have to guess the width of the axis + // labels it must clear. That guess is wrong often enough to be worse than + // the indent, so the key stays where the renderer measures it. if (l.orient === 'top-right' || l.orient === 'top-left') { config.legend.fillColor = d.surface.plot; config.legend.padding = 4; @@ -3260,7 +3710,18 @@ function applyLegend(spec: any, config: any, d: DesignDecisions, table: any[], s const widths = keyWidths(spec, table, l.label.fontSize ?? 10); const block = blockWidth(spec, table); const total = widths.reduce((a, b) => a + b, 0); - if (widths.length > 1 && block && total > block) { + // A row whose keys sit at different depths reads as broken, so an + // uneven pair has two ways out: lay the title beside its entries where + // the row can afford it, or give each key a row of its own. Doing + // neither — which is what happens if the inline title does not fit and + // the keys technically still fit side by side — is the one outcome + // that leaves the ragged row on the page. + const twoKeys = widths.length > 1 && Boolean(block); + const titleRoom = titlesUneven + ? longestKeyTitle(spec) * (l.label.fontSize ?? 10) * 0.55 + 12 + : 0; + const levelInline = twoKeys && titlesUneven && total + titleRoom <= block!; + if (twoKeys && (total > block! || (titlesUneven && !levelInline))) { config.legend.layout = { ...(config.legend.layout ?? {}), [l.orient]: { @@ -3270,20 +3731,26 @@ function applyLegend(spec: any, config: any, d: DesignDecisions, table: any[], s }, }; say('legend.placement', - `${widths.length} keys want ${Math.round(total)}px across a ${block}px block — they take a row each`); + total > block! + ? `${widths.length} keys want ${Math.round(total)}px across a ${block}px block — they take a row each` + : `${widths.length} keys sit at different depths and the row cannot hold an inline title — they take a row each`); // Stacked, each key that still has a title spends a line on it, so // two keys cost four lines above a plot that is only a couple of // hundred pixels tall. The title moves onto the entries' own line // instead — `Population (M) ○ 200 ○ 600 ○ 1,000` — which reads // the way a journalistic key does and hands the row back to the - // chart. It is only offered here because it is only free here: - // stacked keys have the width to spare, whereas keys sharing a row - // are already short of it, and a leading title on each would push - // them apart rather than pull them up. + // chart. config.legend.titleOrient = 'left'; say('legend.titleOrient', 'stacked keys put their title on the same line as their entries — the row is already paid for'); + } else if (levelInline) { + // They fit on one row, but only one of them kept a title, so that + // key is a line deeper than its neighbour and the row hangs + // ragged. Laying its title beside its entries levels the two. + config.legend.titleOrient = 'left'; + say('legend.titleOrient', + 'one key keeps a title and the other does not — its title moves onto the entries\' line so both keys sit at the same depth'); } } @@ -3386,6 +3853,22 @@ function wrapWideKeys( }); } +/** The longest title still standing on any key, in characters. */ +function longestKeyTitle(spec: any): number { + let longest = 0; + walk(spec, (node) => { + for (const channel of ['color', 'fill', 'stroke', 'size', 'shape', 'opacity'] as const) { + const enc = node.encoding?.[channel]; + if (!enc?.field || enc.legend === null) continue; + const title = enc.legend?.title; + if (title === null) continue; + const text = typeof title === 'string' ? title : String(enc.field); + longest = Math.max(longest, text.length); + } + }); + return longest; +} + /** * Roughly how wide each key drawn on this chart wants to be. * @@ -3600,36 +4083,59 @@ function applyDataLabels(spec: any, d: DesignDecisions, table: any[], say: (p: s /** * The ink for a number printed on a ramp. * - * Vega-Lite cannot ask a mark what colour it ended up, so the places where the - * ramp goes dark are worked out here and restated as a test on the value. A - * sequential ramp darkens at one end and a diverging one at both, and the same - * scan covers either. + * Vega-Lite cannot ask a mark what colour it ended up. A parallel quantized + * scale over the same (possibly aggregated) measure maps each ramp interval to + * readable light or dark text. Keeping the aggregate on the encoding lets + * Vega-Lite derive the same per-cell domain for both fill and label ink. */ -function inkOnRamp(field: string, stops: string[], values: number[], light: string, dark: string): any { - const lo = Math.min(...values); - const hi = Math.max(...values); - if (!(hi > lo) || stops.length < 2) return { value: dark }; +function inkScaleOnRamp(enc: any, stops: string[], light: string, dark: string): any { + if (stops.length < 2) return { value: dark }; + const discreteScale = ['quantile', 'quantize', 'threshold', 'bin-ordinal'].includes(enc.scale?.type); + if (discreteScale && Array.isArray(enc.scale?.range) && enc.scale.range.length > 0) { + return { + field: enc.field, + type: 'quantitative', + ...(enc.aggregate ? { aggregate: enc.aggregate } : {}), + legend: null, + scale: { + ...enc.scale, + range: enc.scale.range.map((fill: string) => readableOn(fill, light, dark)), + }, + }; + } const STEPS = 32; - const runs: Array<[number, number]> = []; - for (let i = 0; i <= STEPS; i++) { - const t = (i / STEPS) * (stops.length - 1); + const range: string[] = []; + const lightColor = parseColor(light); + const darkColor = parseColor(dark); + const contrast = (a: ReturnType, b: ReturnType): number => { + if (!a || !b) return 0; + const lo = Math.min(luminance(a), luminance(b)); + const hi = Math.max(luminance(a), luminance(b)); + return (hi + 0.05) / (lo + 0.05); + }; + for (let i = 0; i < STEPS; i++) { + const t = ((i + 0.5) / STEPS) * (stops.length - 1); const a = parseColor(stops[Math.floor(t)]); const b = parseColor(stops[Math.min(stops.length - 1, Math.floor(t) + 1)]); - if (!a || !b) continue; + if (!a || !b) { + range.push(dark); + continue; + } const k = t - Math.floor(t); const c = { r: a.r + (b.r - a.r) * k, g: a.g + (b.g - a.g) * k, b: a.b + (b.b - a.b) * k, a: 1 }; - if (luminance(c) >= 0.5) continue; - const v = lo + (i / STEPS) * (hi - lo); - const last = runs[runs.length - 1]; - if (last && i > 0 && last[1] >= lo + ((i - 1) / STEPS) * (hi - lo)) last[1] = v; - else runs.push([v, v]); + range.push(contrast(lightColor, c) >= contrast(darkColor, c) ? light : dark); } - if (runs.length === 0) return { value: dark }; - const f = `datum[${JSON.stringify(field)}]`; - const test = runs - .map(([a, b]) => (a === b ? `${f} === ${a}` : `(${f} >= ${a} && ${f} <= ${b})`)) - .join(' || '); - return { condition: { test, value: light }, value: dark }; + return { + field: enc.field, + type: 'quantitative', + ...(enc.aggregate ? { aggregate: enc.aggregate } : {}), + legend: null, + scale: { + ...(enc.scale?.domain ? { domain: enc.scale.domain } : {}), + type: 'quantize', + range, + }, + }; } function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): any { @@ -3679,6 +4185,14 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa say('dataLabels', `measure channel \`${measureChannel}\` has no field`); return; } + // A column the template computed to draw itself is not a quantity anyone + // asked to read: a waterfall binds its bars to where each step *starts*, + // so printing it labels the running total under the bar, not the step. + if (String(measure.field).startsWith('__')) { + say('dataLabels', + `the measure is the template's own \`${measure.field}\` — a working column, not a value to print`); + return; + } // A stacked segment *can* carry its value, but only in the middle of it. // At the segment edge the number reads as the running total, which is why // it used to be refused outright; centred, it reads as the segment — the @@ -3847,7 +4361,12 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa } const labelEncoding: any = { - text: { field: measure.field, type: 'quantitative', ...(d.dataLabels.format ? { format: d.dataLabels.format } : {}) }, + text: { + field: measure.field, + type: 'quantitative', + ...(measure.aggregate ? { aggregate: measure.aggregate } : {}), + ...(d.dataLabels.format ? { format: d.dataLabels.format } : {}), + }, }; if (d.dataLabels.unit) { printWithUnit(body, { encoding: labelEncoding }, measure.field, d.dataLabels.format, d.dataLabels.unit); @@ -3989,9 +4508,8 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa if (cells) { // The cell under the number is the ramp, so the ink follows the ramp. - const values = table.map((r) => r?.[measure.field]).filter((v) => typeof v === 'number'); const stops = d.series.ramp?.stops ?? d.series.range ?? []; - labelEncoding.color = inkOnRamp(measure.field, stops, values, d.text.inverse, d.text.primary); + labelEncoding.color = inkScaleOnRamp(measure, stops, d.text.inverse, d.text.primary); } else if (d.dataLabels.inkMode === 'matchSeries' && enc.color?.field) { labelEncoding.color = { ...enc.color, legend: null }; } else if (perCategoryInk) { @@ -4029,7 +4547,7 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa ...(stackedTransform ?? []), ]; if (labelTransforms.length > 0) layer.transform = labelTransforms; - if (perCategoryInk) { + if (perCategoryInk || (cells && labelEncoding.color?.field)) { // Vega-Lite merges scales of the same channel across a layer, so the // label's ink range and the mark's fill range are two answers to one // question and the fill wins — leaving the number painted its own @@ -4469,13 +4987,13 @@ function applySeriesEndLabels( }; appendLayer(body, labelLayer); - // The labels live outside the plot rectangle; the canvas has to make room - // or Vega-Lite will draw them over whatever is next to the chart. At the - // head of a list they sit inside it and no room is needed. - if (atEnd) { - const longest = estimateLongestLabel(table, seriesField) + (merged ? 6 : 0); - growPadding(spec, domainChannel === 'x' ? 'right' : 'top', longest * (t.fontSize ?? 10) * 0.55 + 8); - } + // The names sit outside the plot rectangle, and no room is reserved for + // them here: they are ordinary text marks, and Vega's default `pad` + // autosize already grows the canvas to the scene's own bounds. Estimating + // the same width a second time only double-counted it — measured at ~124px + // of dead margin on a three-series line — and the estimate could never + // agree with the text the renderer actually laid out. + // Naming the series at the line end frees the corner the colour key used to // hold — but a *second* encoding (a forecast dash, a shape) still keeps its // own key, and Vega-Lite parks it top-right by default, right where the end @@ -4687,11 +5205,8 @@ function bandEndLabels( if (!outside.length) appendLayer(body, endLayer(true)); if (outside.length) { appendLayer(body, endLayer(false)); - // Name only out here, so the margin is measured off the longest name - // plus a little air — not off a name-and-number pair that is no longer - // drawn. - const longest = Math.max(...outside.map((s) => s.length)) + 2; - growPadding(spec, domainChannel === 'x' ? 'right' : 'top', longest * (t.fontSize ?? 10) * 0.55 + 8); + // No margin is reserved for these names: `pad` autosize already sizes + // the canvas to the scene, so reserving it here counted it twice. say('legend.placement', homeless.length === order.length ? 'the bands climb away from their own labels — the names sit outside the plot in series ink, as a list' @@ -4816,16 +5331,6 @@ function orderedValues(table: any[], field: string): string[] { return seen; } -function estimateLongestLabel(table: any[], field: string): number { - if (!Array.isArray(table)) return 10; - let max = 0; - for (const row of table) { - const v = row?.[field]; - if (v != null) max = Math.max(max, String(v).length); - } - return max || 10; -} - // --------------------------------------------------------------------------- // Point emphasis // --------------------------------------------------------------------------- @@ -5277,10 +5782,37 @@ function widestWidth(spec: any, table: any[]): number | undefined { if (typeof spec.width === 'number') consider(spec.width); // A chart that states no width of its own is drawn at the one the layout // put in the view config — that is the width, not a default to guess at. - if (widest == null) consider(spec.config?.view?.continuousWidth); + // Except when the axis is banded: Vega-Lite sizes those one step per + // category and never reads `continuousWidth`, so taking it here would run + // the rule well past the plot and stretch the canvas out to meet it. + if (widest == null) consider(bandedWidth(spec, table) ?? spec.config?.view?.continuousWidth); return widest; } +/** + * The width Vega-Lite gives a banded plot that states no step of its own: + * one step per category. Returns undefined when the x axis is not banded. + */ +function bandedWidth(spec: any, table: any[]): number | undefined { + let enc: any; + walk(spec, (node) => { + const x = node.encoding?.x; + if (!enc && x?.field && (x.type === 'nominal' || x.type === 'ordinal')) enc = x; + }); + if (!enc) return undefined; + // A template may compute its categories in a transform, in which case the + // table has no column to count — the stated domain is the count. + const count = Array.isArray(enc.scale?.domain) && enc.scale.domain.length + ? enc.scale.domain.length + : distinctCount(table, enc.field); + if (!count) return undefined; + const discrete = spec.config?.view?.discreteWidth; + const step = typeof discrete === 'number' + ? undefined + : discrete?.step ?? spec.config?.view?.step ?? 20; + return typeof discrete === 'number' ? discrete : step * count; +} + function blockWidth(spec: any, table: any[]): number | undefined { const widest = widestWidth(spec, table); if (widest == null) return undefined; diff --git a/packages/flint-js/tests/calendar-vegalite.test.ts b/packages/flint-js/tests/calendar-vegalite.test.ts new file mode 100644 index 00000000..091dda6a --- /dev/null +++ b/packages/flint-js/tests/calendar-vegalite.test.ts @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite, assembleECharts, vlGetTemplateDef } from '../src'; +import { THEME_PRESETS } from '../src/core/theme/presets'; + +/** + * Vega-Lite Calendar Heatmap — parity with the ECharts calendar template. + * + * Vega-Lite has no first-class calendar coordinate system, so the grid derives + * a UTC Monday week-start and weekday category from the date field. `sum` + * collapses rows that share a calendar day into one cell. + */ + +const DAILY = [ + { date: '2024-01-01', value: 5 }, + { date: '2024-01-02', value: 9 }, + { date: '2024-01-02', value: 1 }, // same day → summed with the row above + { date: '2024-01-08', value: 12 }, + { date: '2024-02-05', value: 3 }, +]; + +function calInput(extraEnc?: Record) { + return { + data: { values: DAILY }, + semantic_types: { date: 'Date', value: 'Amount' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: { field: 'date' }, color: { field: 'value', ...extraEnc } }, + baseSize: { width: 420, height: 160 }, + }, + }; +} + +function calendarCell(spec: any): { mark: any; encoding: any } { + let found: { mark: any; encoding: any } | undefined; + const visit = (node: any, inherited: any = {}) => { + if (!node || found) return; + const encoding = { ...inherited, ...(node.encoding ?? {}) }; + if (!node.__themeSynthetic && (node.mark?.type ?? node.mark) === 'rect' && encoding.color) { + found = { mark: node.mark, encoding }; + return; + } + for (const key of ['layer', 'concat', 'hconcat', 'vconcat']) { + for (const child of node[key] ?? []) visit(child, encoding); + } + if (node.spec) visit(node.spec, encoding); + }; + visit(spec); + if (!found) throw new Error('Calendar cell mark not found'); + return found; +} + +function hasIndependentColorScale(node: any): boolean { + if (!node || typeof node !== 'object') return false; + if (node.resolve?.scale?.color === 'independent') return true; + return ['layer', 'concat', 'hconcat', 'vconcat'] + .some(key => (node[key] ?? []).some((child: any) => hasIndependentColorScale(child))) + || hasIndependentColorScale(node.spec); +} + +describe('Vega-Lite Calendar Heatmap', () => { + it('is registered in the Vega-Lite template registry', () => { + expect(vlGetTemplateDef('Calendar Heatmap')).toBeDefined(); + }); + + it('draws no plot frame, which would box in the days a part week lacks', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.config.view.stroke).toBeNull(); + }); + + it('lets the caller square off the cells', () => { + const rounded = assembleVegaLite(calInput()) as any; + expect(rounded.mark.cornerRadius).toBe(2); + + const input: any = calInput(); + input.chart_spec.chartProperties = { cornerRadius: 0 }; + expect((assembleVegaLite(input) as any).mark.cornerRadius).toBe(0); + }); + + it('derives UTC Monday-week and weekday fields from the date', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.mark?.type ?? spec.mark).toBe('rect'); + expect(spec.encoding.x.field).toBe('__flintCalendarWeek'); + expect(spec.encoding.x.timeUnit).toBeUndefined(); + expect(spec.encoding.y.field).toBe('__flintCalendarWeekday'); + expect(spec.encoding.y.timeUnit).toBeUndefined(); + expect(spec.transform).toContainEqual({ + calculate: 'utc(utcyear(toDate(datum["__flintCalendarDate"])),utcmonth(toDate(datum["__flintCalendarDate"])),utcdate(toDate(datum["__flintCalendarDate"]))-((utcday(toDate(datum["__flintCalendarDate"]))+6)%7))', + as: '__flintCalendarWeek', + }); + expect(spec.transform).toContainEqual({ + calculate: "['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][utcday(toDate(datum[\"__flintCalendarDate\"]))]", + as: '__flintCalendarWeekday', + }); + }); + + it('prints one month label near each boundary rather than one per week', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.encoding.x.axis.format).toBeUndefined(); + expect(spec.encoding.x.axis.labelExpr).toContain('utcdate(datum.value) <= 7'); + expect(spec.encoding.x.axis.labelExpr).toContain("utcFormat(datum.value, '%b')"); + }); + + it('preserves empty weeks and month labels for sparse calendars', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { date: '2024-01-15', value: 1 }, + { date: '2024-02-15', value: 2 }, + { date: '2024-03-21', value: 3 }, + ], + }, + semantic_types: { date: 'Date', value: 'Amount' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: { field: 'date' }, color: { field: 'value' } }, + baseSize: { width: 420, height: 160 }, + }, + } as any) as any; + expect(spec.encoding.x.scale.domain.length).toBeGreaterThan(3); + expect(spec.encoding.x.scale.domain[0]).toBe(Date.UTC(2024, 0, 1)); + expect(spec.encoding.x.axis.labelExpr).toContain('utcdate(datum.value) <= 7'); + }); + + it('uses timestamp offsets when deriving the explicit UTC week domain', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { date: '2024-09-01T00:30:00+14:00', value: 1 }, + { date: '2024-09-03T12:00:00Z', value: 2 }, + ], + }, + semantic_types: { date: 'Date', value: 'Amount' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: { field: 'date' }, color: { field: 'value' } }, + baseSize: { width: 420, height: 160 }, + }, + } as any) as any; + expect(spec.encoding.x.scale.domain).toContain(Date.UTC(2024, 7, 26)); + }); + + it.each(['2024-01-01T00:00:00', '2024-01-01 00:00:00', ' 2024-01-01 00:00:00 '])( + 'normalizes timezone-less timestamp %s to UTC before deriving cells and domains', + (date) => { + const spec = assembleVegaLite({ + data: { values: [{ date, value: 1 }] }, + semantic_types: { date: 'Date', value: 'Amount' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: { field: 'date' }, color: { field: 'value' } }, + baseSize: { width: 420, height: 160 }, + }, + } as any) as any; + expect(spec.data.values[0].__flintCalendarDate).toBe(Date.UTC(2024, 0, 1)); + expect(spec.encoding.x.scale.domain).toContain(Date.UTC(2024, 0, 1)); + }, + ); + + it('orders weekday rows Monday-first (matches the ECharts calendar)', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.encoding.y.sort).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + }); + + it('sums the value per calendar day', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.encoding.color.field).toBe('value'); + expect(spec.encoding.color.aggregate).toBe('sum'); + expect(spec.encoding.color.type).toBe('quantitative'); + }); + + it('falls back to a derived per-day count when no value field is given', () => { + const input = { + data: { values: DAILY }, + semantic_types: { date: 'Date' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: { field: 'date' } }, + baseSize: { width: 420, height: 160 }, + }, + }; + const spec = assembleVegaLite(input) as any; + expect(spec.transform).toContainEqual({ calculate: '1', as: '__flintCalendarCount' }); + expect(spec.encoding.color.aggregate).toBe('sum'); + expect(spec.encoding.color.field).toBe('__flintCalendarCount'); + }); + + it.each([ + ['value-backed', calInput()], + ['count-only', { + data: { values: DAILY }, + semantic_types: { date: 'Date' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: { field: 'date' } }, + baseSize: { width: 420, height: 160 }, + }, + }], + ])('applies the house ramp, tile gap, type, and surface to %s calendars', (_name, input) => { + const spec = assembleVegaLite({ ...input, theme_spec: THEME_PRESETS.pop.spec } as any) as any; + const cell = calendarCell(spec); + expect(cell.encoding.color.scale.scheme).toBeUndefined(); + expect(cell.encoding.color.scale.range).toEqual(spec._theme.decisions.series.range); + expect(cell.mark.stroke).toBe(spec._theme.decisions.marks.tile.color); + expect(cell.mark.strokeWidth).toBe(spec._theme.decisions.marks.tile.gap); + expect(spec.config.axisX.labelFont).toBe(spec._theme.decisions.axes.x.label.font); + expect(spec.background).toBe(spec._theme.decisions.surface.canvas); + }); + + it.each(Object.keys(THEME_PRESETS))('realizes the complete %s house on the calendar', (themeId) => { + const spec = assembleVegaLite({ ...calInput(), theme_spec: themeId } as any) as any; + const cell = calendarCell(spec); + expect(spec._theme.id).toBe(themeId); + expect(cell.encoding.color.scale.range).toEqual(spec._theme.decisions.series.range); + expect(spec.config.axisX.labelFont).toBe(spec._theme.decisions.axes.x.label.font); + expect(spec.background).toBe(spec._theme.decisions.surface.canvas); + }); + + it('aggregates theme-added cell labels with the calendar values', () => { + const spec = assembleVegaLite({ ...calInput(), theme_spec: 'pop' } as any) as any; + const label = spec.layer.find((layer: any) => + layer.__themeSynthetic && (layer.mark?.type ?? layer.mark) === 'text'); + expect(label.encoding.text.field).toBe('value'); + expect(label.encoding.text.aggregate).toBe('sum'); + expect(label.encoding.color.field).toBe('value'); + expect(label.encoding.color.aggregate).toBe('sum'); + expect(label.encoding.color.scale.type).toBe('quantize'); + }); + + it.each(['swiss', 'datawrapper', 'cartoon'])( + 'keeps the %s label-contrast scale independent from the cell-fill scale', + (themeId) => { + const spec = assembleVegaLite({ ...calInput(), theme_spec: themeId } as any) as any; + expect(hasIndependentColorScale(spec)).toBe(true); + }, + ); + + it('mirrors quantile fill bins when choosing aggregate label inks', () => { + const input = calInput({ scheme: 'github' }); + const spec = assembleVegaLite({ + ...input, + theme_spec: { + id: 'contrast', + label: 'Contrast', + ink: { + surface: { canvas: '#ffffff' }, + text: { primary: '#111111', inverse: '#ffffff' }, + series: { single: '#333333', sequential: { stops: ['#ffffff', '#000000'] } }, + }, + dataLabels: { show: 'always', placement: 'atMark', inkMode: 'contrastWithMark' }, + }, + } as any) as any; + const cell = calendarCell(spec); + const label = spec.layer.find((layer: any) => + layer.__themeSynthetic && (layer.mark?.type ?? layer.mark) === 'text'); + expect(cell.encoding.color.scale.type).toBe('quantile'); + expect(label.encoding.color.scale.type).toBe('quantile'); + expect(label.encoding.color.scale.range).toHaveLength(cell.encoding.color.scale.range.length); + }); + + it('resolves the github scheme to a quantile scale over the canonical 5-bucket range', () => { + const spec = assembleVegaLite(calInput({ scheme: 'github' })) as any; + expect(spec.encoding.color.scale.scheme).toBeUndefined(); + // Quantile scale → discrete GitHub buckets, not a continuous ramp. + expect(spec.encoding.color.scale.type).toBe('quantile'); + expect(spec.encoding.color.scale.range).toEqual([ + '#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39', + ]); + }); + + it('passes a named scheme straight through to scale.scheme', () => { + const spec = assembleVegaLite(calInput({ scheme: 'blues' })) as any; + expect(spec.encoding.color.scale.scheme).toBe('blues'); + }); + + it('assembles the same chart type on both backends (registry parity)', () => { + const vl = assembleVegaLite(calInput()) as any; + const ec = assembleECharts(calInput()) as any; + expect(vl.encoding.x.field).toBe('__flintCalendarWeek'); // VL: derived week grid + expect(ec.series?.[0]?.coordinateSystem).toBe('calendar'); // EC: calendar coord + }); +}); diff --git a/packages/flint-js/tests/heatmap-colors.test.ts b/packages/flint-js/tests/heatmap-colors.test.ts index f260135c..08e30aaa 100644 --- a/packages/flint-js/tests/heatmap-colors.test.ts +++ b/packages/flint-js/tests/heatmap-colors.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { describe, expect, it } from 'vitest'; +import { compile } from 'vega-lite'; import { assembleECharts, assembleExcel, assembleVegaLite } from '../src'; const HEATMAP_DATA = [ @@ -127,6 +128,71 @@ describe('heatmap color defaults', () => { .toEqual(spec.layer[0].encoding.y.scale.domain); }); + it('infers a numeric year field as temporal when semantic types are missing', () => { + const values = Array.from({ length: 19 }, (_, index) => ({ + year: 2006 + index, + item: 'Bananas, per lb.', + monthly_volatility: 11.27 - index / 10, + })); + + const spec = assembleVegaLite({ + data: { values }, + semantic_types: {}, + chart_spec: { + chartType: 'Heatmap', + encodings: { + x: { field: 'year' }, + y: { field: 'item' }, + color: { field: 'monthly_volatility', scheme: 'oranges' }, + }, + baseSize: { width: 400, height: 300 }, + canvasSize: { width: 600, height: 450 }, + chartProperties: {}, + }, + options: { addTooltips: true }, + } as any) as any; + + expect(spec.encoding.x.type).toBe('temporal'); + expect(spec.encoding.x.axis?.format).toBeUndefined(); + expect(spec.data.values.map((row: any) => row.year)).toEqual( + values.map(row => String(row.year)), + ); + + const compiled = compile(spec).spec as any; + const xScale = compiled.scales.find((scale: any) => scale.name === 'x'); + expect(xScale.type).toBe('time'); + }); + + it('does not zero-expand an explicit cell domain on an untyped quantitative axis', () => { + const values = Array.from({ length: 19 }, (_, index) => ({ + position: 2006 + index, + item: 'Bananas, per lb.', + value: 11.27 - index / 10, + })); + const spec = assembleVegaLite({ + data: { values }, + semantic_types: {}, + chart_spec: { + chartType: 'Heatmap', + encodings: { x: 'position', y: 'item', color: 'value' }, + }, + } as any) as any; + + expect(spec.encoding.x).toMatchObject({ + type: 'quantitative', + scale: { zero: false, nice: false, domain: [2005.5, 2024.5] }, + }); + + const compiled = compile(spec).spec as any; + const xScale = compiled.scales.find((scale: any) => scale.name === 'x'); + expect(xScale).toMatchObject({ + type: 'linear', + domain: [2005.5, 2024.5], + nice: false, + zero: false, + }); + }); + it('retains two true temporal axes for a dense 2,400-cell time heatmap', () => { const values = []; for (let x = 0; x < 60; x += 1) { diff --git a/packages/flint-js/tests/line-continuous-color.test.ts b/packages/flint-js/tests/line-continuous-color.test.ts index 41687797..688dc12e 100644 --- a/packages/flint-js/tests/line-continuous-color.test.ts +++ b/packages/flint-js/tests/line-continuous-color.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { describe, it, expect } from 'vitest'; -import { assembleVegaLite } from '../src'; +import { assemblePlotly, assembleVegaLite } from '../src'; import { genLineTests } from '../src/test-data'; import type { TestCase } from '../src/test-data/types'; @@ -46,6 +46,50 @@ describe('Vega-Lite Line Chart — continuous color', () => { expect(spec.layer[1].encoding.color.type).toBe('quantitative'); }); + describe('Plotly Line Chart — continuous color', () => { + const cases = genLineTests(); + + it('uses one neutral path plus color-scaled secondary points', () => { + const tc = cases.find((t) => t.description === 'Continuous color gradient on time series')!; + const fig = assemblePlotly(toInput(tc)) as any; + + expect(fig.data).toHaveLength(2); + expect(fig.data[0].mode).toBe('lines'); + expect(fig.data[0].x).toHaveLength(tc.data.length); + expect(fig.data[0].showlegend).toBe(false); + expect(fig.data[1].mode).toBe('markers'); + expect(fig.data[1].marker.color).toHaveLength(tc.data.length); + expect(fig.data[1].marker.showscale).toBe(true); + expect(fig.data[1]._markerRole).toBe('secondary'); + expect(fig.layout.showlegend).toBe(false); + }); + + it('retains dash groups alongside a continuous color scale', () => { + const input = { + data: { + values: [ + { x: 1, y: 10, score: 0.1, state: 'Actual' }, + { x: 2, y: 12, score: 0.2, state: 'Actual' }, + { x: 2, y: 12, score: 0.2, state: 'Forecast' }, + { x: 3, y: 15, score: 0.9, state: 'Forecast' }, + ], + }, + semantic_types: { x: 'Quantity', y: 'Quantity', score: 'Quantity', state: 'Category' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'x', y: 'y', color: 'score', strokeDash: 'state' }, + baseSize: { width: 500, height: 300 }, + }, + }; + const fig = assemblePlotly(input as any) as any; + expect(fig.data.filter((t: any) => t.mode === 'lines').map((t: any) => t.line.dash)) + .toEqual(['solid', 'dash']); + expect(fig.data.filter((t: any) => t.mode === 'markers')).toHaveLength(2); + expect(fig.data.filter((t: any) => t.marker?.showscale)).toHaveLength(1); + expect(fig.layout.showlegend).toBe(true); + }); + }); + it('uses a neutral line layer plus colored points for T×Q + color(Q)', () => { const tc = cases.find((t) => t.description === 'Continuous color gradient on time series')!; const spec = assembleVegaLite(toInput(tc)) as any; diff --git a/packages/flint-js/tests/pie-overflow.test.ts b/packages/flint-js/tests/pie-overflow.test.ts new file mode 100644 index 00000000..0a9bdc01 --- /dev/null +++ b/packages/flint-js/tests/pie-overflow.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assemblePlotly, assembleVegaLite } from '../src'; +import { THEME_PRESETS } from '../src/core/theme/presets'; + +const values = Array.from({ length: 25 }, (_, i) => ({ + Region: `Region ${i + 1}`, + Sales: i + 1, +})); + +const input = () => ({ + data: { values }, + semantic_types: { Region: 'Category', Sales: 'Quantity' }, + chart_spec: { + chartType: 'Pie Chart', + encodings: { color: 'Region', size: 'Sales' }, + baseSize: { width: 420, height: 360 }, + }, + theme_spec: THEME_PRESETS.nyt.spec, +} as never); + +describe('high-cardinality pie overflow', () => { + it('Plotly keeps the largest palette tier and sums the tail into Others', () => { + const figure = assemblePlotly(input()) as any; + const trace = figure.data[0]; + + // NYT has 12 extended categorical inks: 12 named slices plus one + // overflow slice replaces the original 25-way colour cycle. + expect(trace.labels).toHaveLength(13); + expect(trace.labels.slice(0, 3)).toEqual(['Region 25', 'Region 24', 'Region 23']); + expect(trace.labels.at(-1)).toBe('Others (13)'); + expect(trace.values.at(-1)).toBe(91); // 1 + ... + 13 + expect(trace.marker.colors).toHaveLength(13); + expect(trace.marker.colors.at(-1)).toBe('#9e9e9e'); + expect(figure._theme.report.some((entry: any) => + entry.path === 'ink.series.categorical' + && entry.message.includes('Others (13)'))).toBe(true); + }); + + it('Vega-Lite exposes the same top-12 plus Others domain', () => { + const spec = assembleVegaLite(input()) as any; + expect(spec.encoding.color.field).toBe('__flintColorKey'); + expect(spec.encoding.color.scale.domain).toEqual([ + 'Region 25', 'Region 24', 'Region 23', 'Region 22', + 'Region 21', 'Region 20', 'Region 19', 'Region 18', + 'Region 17', 'Region 16', 'Region 15', 'Region 14', + 'Others (13)', + ]); + expect(spec.transform.some((t: any) => + Array.isArray(t.aggregate) + && t.groupby?.includes('__flintColorKey'))).toBe(true); + }); +}); diff --git a/packages/flint-js/tests/plotly-backend.test.ts b/packages/flint-js/tests/plotly-backend.test.ts index 5c06a4f4..a1f76421 100644 --- a/packages/flint-js/tests/plotly-backend.test.ts +++ b/packages/flint-js/tests/plotly-backend.test.ts @@ -188,8 +188,10 @@ describe('Plotly backend', () => { const categorical = assemblePlotly(input('Scatter Plot', { x: { field: 'weight' }, y: { field: 'mpg' }, color: { field: 'origin' } }, CARS, { weight: 'Quantity', mpg: 'Quantity', origin: 'Country' })); // Categorical grouping draws from the plotly10 qualitative palette… expect(categorical.data[0].marker.color).toBe('#636efa'); - // …while a diverging measure on color routes to the RdBu ramp. - expect(diverging.data[0].marker.color).toBe('#b2182b'); + // …while a diverging measure on color preserves the values on an RdBu ramp. + expect(diverging.data[0].marker.color).toEqual([-6, 14, 24]); + expect(diverging.data[0].marker.colorscale).toBe('RdBu'); + expect(diverging.data[0].marker.showscale).toBe(true); }); it('every dedicated generator case assembles cleanly', () => { diff --git a/packages/flint-js/tests/plotly-expressive.test.ts b/packages/flint-js/tests/plotly-expressive.test.ts index de631853..2c449325 100644 --- a/packages/flint-js/tests/plotly-expressive.test.ts +++ b/packages/flint-js/tests/plotly-expressive.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect } from 'vitest'; import { assemblePlotly, plAllTemplateDefs, plGetTemplateDef } from '../src'; +import { plCombineFacetPanels } from '../src/plotly/facet'; function input(chartType: string, encodings: Record, values: any[], semantic_types: Record, chartProperties?: Record) { return { @@ -96,6 +97,325 @@ describe('Plotly expressive templates — registration', () => { }); describe('Plotly expressive templates — native trace shapes', () => { + it('keeps the first resolved category at the top of horizontal bars', () => { + const fig = assemblePlotly(input( + 'Bar Chart', + { y: { field: 'region', sortBy: 'x', sortOrder: 'descending' }, x: { field: 'revenue' } }, + SALES, + { region: 'Region', revenue: 'Amount' }, + )) as any; + expect(fig.layout.yaxis.autorange).toBe('reversed'); + }); + + it('uses a continuous scale for quantitative bar color', () => { + const fig = assemblePlotly(input( + 'Bar Chart', + { x: { field: 'category' }, y: { field: 'value' }, color: { field: 'score' } }, + [ + { category: 'A', value: 10, score: 0.2 }, + { category: 'B', value: 20, score: 0.8 }, + ], + { category: 'Category', value: 'Quantity', score: 'Quantity' }, + )) as any; + expect(fig.data).toHaveLength(1); + expect(fig.data[0].marker.color).toEqual([0.2, 0.8]); + expect(fig.data[0].marker.showscale).toBe(true); + expect(fig.layout.showlegend).toBe(false); + }); + + it('fits slope values rather than forcing a zero baseline', () => { + const fig = assemblePlotly(input( + 'Slope Chart', + { x: { field: 'year' }, y: { field: 'revenue' }, color: { field: 'region' } }, + SALES, + { year: 'Year', revenue: 'Amount', region: 'Region' }, + )) as any; + expect(fig.layout.yaxis.rangemode).toBe('normal'); + }); + + it('groups line traces by stroke dash and retains the dash in the legend', () => { + const values = [ + { year: 2023, value: 10, state: 'Observed' }, + { year: 2024, value: 12, state: 'Observed' }, + { year: 2024, value: 12, state: 'Projected' }, + { year: 2025, value: 15, state: 'Projected' }, + ]; + const fig = assemblePlotly(input( + 'Line Chart', + { x: { field: 'year' }, y: { field: 'value' }, strokeDash: { field: 'state' } }, + values, + { year: 'Year', value: 'Amount', state: 'Category' }, + )) as any; + expect(fig.data.map((t: any) => t.name)).toEqual(['Observed', 'Projected']); + expect(fig.data.map((t: any) => t.line.dash)).toEqual(['solid', 'dash']); + expect(fig.layout.showlegend).toBe(true); + }); + + it('shows goal progress on numeric KPI cards', () => { + const fig = assemblePlotly(input( + 'KPI Card', + { metric: { field: 'metric' }, value: { field: 'value' }, goal: { field: 'goal' } }, + [{ metric: 'Renewable share', value: 30, goal: 45 }], + { metric: 'Category', value: 'Quantity', goal: 'Quantity' }, + )) as any; + expect(fig.data[0].mode).toBe('number+delta'); + expect(fig.layout.shapes).toHaveLength(2); + expect(fig.layout.annotations[0].text).toBe('67% of 45'); + }); + + it('does not divide by zero for a zero KPI goal', () => { + const fig = assemblePlotly(input( + 'KPI Card', + { metric: { field: 'metric' }, value: { field: 'value' }, goal: { field: 'goal' } }, + [{ metric: 'Defects', value: 0, goal: 0 }], + { metric: 'Category', value: 'Quantity', goal: 'Quantity' }, + )) as any; + expect(fig.layout.shapes).toHaveLength(0); + expect(fig.layout.annotations[0].text).toBe('Goal: 0'); + expect(JSON.stringify(fig)).not.toContain('NaN'); + }); + + it('preserves categorical shape as marker symbols and legend entries', () => { + const values = [ + { x: 1, y: 2, kind: 'A' }, + { x: 2, y: 3, kind: 'B' }, + { x: 3, y: 4, kind: 'A' }, + ]; + const fig = assemblePlotly(input( + 'Scatter Plot', + { x: { field: 'x' }, y: { field: 'y' }, shape: { field: 'kind' } }, + values, + { x: 'Quantity', y: 'Quantity', kind: 'Category' }, + )) as any; + expect(fig.data.map((t: any) => t.name)).toEqual(['A', 'B']); + expect(fig.data.map((t: any) => t.marker.symbol)).toEqual(['circle', 'square']); + expect(fig.layout.showlegend).toBe(true); + }); + + it('retains shape groups alongside continuous scatter color', () => { + const values = [ + { x: 1, y: 2, score: 0.1, kind: 'A' }, + { x: 2, y: 3, score: 0.5, kind: 'B' }, + { x: 3, y: 4, score: 0.9, kind: 'A' }, + ]; + const fig = assemblePlotly(input( + 'Scatter Plot', + { + x: { field: 'x' }, + y: { field: 'y' }, + color: { field: 'score' }, + shape: { field: 'kind' }, + }, + values, + { x: 'Quantity', y: 'Quantity', score: 'Quantity', kind: 'Category' }, + )) as any; + expect(fig.data.map((t: any) => t.name)).toEqual(['A', 'B']); + expect(fig.data.map((t: any) => t.marker.symbol)).toEqual(['circle', 'square']); + expect(fig.data.filter((t: any) => t.marker.showscale)).toHaveLength(1); + expect(fig.layout.showlegend).toBe(true); + }); + + it('orients heatmap category rows like Vega-Lite', () => { + const fig = assemblePlotly(input( + 'Heatmap', + { x: { field: 'x' }, y: { field: 'y' }, color: { field: 'value' } }, + [{ x: 'A', y: 'First', value: 1 }, { x: 'A', y: 'Second', value: 2 }], + { x: 'Category', y: 'Category', value: 'Quantity' }, + )) as any; + expect(fig.layout.yaxis.autorange).toBe('reversed'); + }); + + it('uses a continuous colorbar instead of fifty grouped-bar legend keys', () => { + const values = Array.from({ length: 10 }, (_v, i) => ({ + category: i < 5 ? 'A' : 'B', + value: i + 1, + temperature: 10.5 + i / 10, + })); + const fig = assemblePlotly(input( + 'Grouped Bar Chart', + { x: { field: 'category' }, y: { field: 'value' }, color: { field: 'temperature' } }, + values, + { category: 'Category', value: 'Quantity', temperature: 'Quantity' }, + )) as any; + expect(fig.data).toHaveLength(5); + expect(fig.data[0].marker.color).toHaveLength(2); + expect(fig.data[0].marker.showscale).toBe(true); + expect(fig.layout.showlegend).toBe(false); + }); + + it('maps temporal grouped-bar color to timestamps and preserves missing values', () => { + const fig = assemblePlotly(input( + 'Grouped Bar Chart', + { x: { field: 'category' }, y: { field: 'value' }, color: { field: 'when' } }, + [ + { category: 'A', value: 10, when: '2024-01-01' }, + { category: 'B', value: 20, when: null }, + ], + { category: 'Category', value: 'Quantity', when: 'Date' }, + )) as any; + expect(fig.data[0].marker.color[0]).toBe(Date.parse('2024-01-01')); + expect(fig.data[0].marker.color[1]).toBeNull(); + }); + + it('places polar facets on separate Plotly subplots', () => { + const values = [ + { direction: 'N', value: 10, year: '2023' }, + { direction: 'S', value: 8, year: '2023' }, + { direction: 'N', value: 12, year: '2024' }, + { direction: 'S', value: 9, year: '2024' }, + ]; + const fig = assemblePlotly(input( + 'Rose Chart', + { x: { field: 'direction' }, y: { field: 'value' }, column: { field: 'year' } }, + values, + { direction: 'Category', value: 'Quantity', year: 'Year' }, + )) as any; + expect(fig.layout.polar.domain).toBeDefined(); + expect(fig.layout.polar2.domain).toBeDefined(); + expect(new Set(fig.data.map((t: any) => t.subplot))).toEqual(new Set(['polar', 'polar2'])); + }); + + it('retains stacking when colored rose panels are faceted', () => { + const values = [ + { direction: 'N', value: 10, year: '2023', series: 'A' }, + { direction: 'N', value: 5, year: '2023', series: 'B' }, + { direction: 'N', value: 12, year: '2024', series: 'A' }, + { direction: 'N', value: 6, year: '2024', series: 'B' }, + ]; + const fig = assemblePlotly(input( + 'Rose Chart', + { + x: { field: 'direction' }, + y: { field: 'value' }, + color: { field: 'series' }, + column: { field: 'year' }, + }, + values, + { direction: 'Category', value: 'Quantity', series: 'Category', year: 'Year' }, + )) as any; + expect(fig.layout.barmode).toBe('stack'); + }); + + it('retains normalized stacking in cartesian facets', () => { + const values = [ + { category: 'A', value: 20, series: 'One', panel: 'Left' }, + { category: 'A', value: 80, series: 'Two', panel: 'Left' }, + { category: 'A', value: 30, series: 'One', panel: 'Right' }, + { category: 'A', value: 70, series: 'Two', panel: 'Right' }, + ]; + const fig = assemblePlotly(input( + 'Stacked Bar Chart', + { + x: { field: 'category' }, + y: { field: 'value' }, + color: { field: 'series' }, + column: { field: 'panel' }, + }, + values, + { category: 'Category', value: 'Quantity', series: 'Category', panel: 'Category' }, + { stackMode: 'normalize' }, + )) as any; + expect(fig.layout.barmode).toBe('stack'); + expect(fig.layout.barnorm).toBe('percent'); + }); + + it('keeps one colorbar and no categorical key across continuous-color facets', () => { + const values = [ + { category: 'A', value: 10, score: 1, panel: 'Left' }, + { category: 'B', value: 20, score: 2, panel: 'Left' }, + { category: 'A', value: 30, score: 3, panel: 'Right' }, + { category: 'B', value: 40, score: 4, panel: 'Right' }, + ]; + const fig = assemblePlotly(input( + 'Bar Chart', + { + x: { field: 'category' }, + y: { field: 'value' }, + color: { field: 'score' }, + column: { field: 'panel' }, + }, + values, + { category: 'Category', value: 'Quantity', score: 'Quantity', panel: 'Category' }, + )) as any; + expect(fig.data.filter((trace: any) => trace.marker?.showscale)).toHaveLength(1); + expect(fig.data.every((trace: any) => trace.showlegend === false)).toBe(true); + expect(new Set(fig.data.map((trace: any) => trace.marker.cmin))).toEqual(new Set([1])); + expect(new Set(fig.data.map((trace: any) => trace.marker.cmax))).toEqual(new Set([4])); + }); + + it('globalizes and deduplicates continuous strip-plot facet scales', () => { + const values = [ + { category: 'A', value: 1, score: 0, panel: 'Left' }, + { category: 'A', value: 2, score: 1, panel: 'Left' }, + { category: 'A', value: 3, score: 100, panel: 'Right' }, + { category: 'A', value: 4, score: 200, panel: 'Right' }, + ]; + const fig = assemblePlotly(input( + 'Strip Plot', + { + x: { field: 'category' }, + y: { field: 'value' }, + color: { field: 'score' }, + column: { field: 'panel' }, + }, + values, + { category: 'Category', value: 'Quantity', score: 'Quantity', panel: 'Category' }, + )) as any; + expect(fig.data.filter((trace: any) => trace.marker?.showscale)).toHaveLength(1); + expect(new Set(fig.data.map((trace: any) => trace.marker?.cmin))).toEqual(new Set([0])); + expect(new Set(fig.data.map((trace: any) => trace.marker?.cmax))).toEqual(new Set([200])); + }); + + it('globalizes sparse heatmap facet scales and keeps one colorbar', () => { + const values = [ + { x: 'A', y: 'First', value: 100, panel: 'Left' }, + { x: 'B', y: 'First', value: 200, panel: 'Left' }, + { x: 'A', y: 'Second', value: 300, panel: 'Left' }, + { x: 'A', y: 'First', value: 400, panel: 'Right' }, + { x: 'B', y: 'Second', value: 350, panel: 'Right' }, + ]; + const fig = assemblePlotly(input( + 'Heatmap', + { + x: { field: 'x' }, + y: { field: 'y' }, + color: { field: 'value' }, + column: { field: 'panel' }, + }, + values, + { x: 'Category', y: 'Category', value: 'Quantity', panel: 'Category' }, + )) as any; + expect(fig.data.filter((trace: any) => trace.showscale !== false)).toHaveLength(1); + expect(new Set(fig.data.map((trace: any) => trace.zmin))).toEqual(new Set([100])); + expect(new Set(fig.data.map((trace: any) => trace.zmax))).toEqual(new Set([400])); + }); + + it('globalizes large facet scales without spreading values as arguments', () => { + const color = Array.from({ length: 200_000 }, (_value, index) => index); + const fig = plCombineFacetPanels( + [{ + rowIndex: 0, + colIndex: 0, + figure: { + data: [{ type: 'scatter', x: [0], y: [0], marker: { color, showscale: true } }], + layout: {}, + }, + }], + { + rows: 1, + cols: 1, + panelWidth: 400, + panelHeight: 300, + hasColHeader: false, + hasRowHeader: false, + colHeaderPerRow: false, + showLegend: false, + }, + ) as any; + expect(fig.data[0].marker.cmin).toBe(0); + expect(fig.data[0].marker.cmax).toBe(199_999); + }); + it('boxplot uses a native box trace (no manual quartile computation)', () => { const fig = assemblePlotly(input('Boxplot', { x: { field: 'region' }, y: { field: 'revenue' } }, SALES, { region: 'Region', revenue: 'Amount' })); expect(fig.data[0].type).toBe('box'); @@ -163,6 +483,24 @@ describe('Plotly expressive templates — native trace shapes', () => { }); describe('Plotly expressive templates — grouping & stacking', () => { + it('renders a discrete-by-discrete bar request as an occupied square grid', () => { + const rows = [ + { product: 'Laptop', market: 'US' }, + { product: 'Phone', market: 'US' }, + { product: 'Phone', market: 'UK' }, + ]; + const fig = assemblePlotly(input( + 'Bar Chart', + { x: { field: 'product' }, y: { field: 'market' } }, + rows, + { product: 'Category', market: 'Category' }, + )); + expect(fig.data[0].type).toBe('scatter'); + expect(fig.data[0].marker.symbol).toBe('square'); + expect(fig.data[0].x).toHaveLength(3); + expect(fig.layout.yaxis.type).toBe('category'); + }); + it('grouped bar chart uses barmode "group" with one trace per group', () => { const fig = assemblePlotly(input('Grouped Bar Chart', { x: { field: 'region' }, y: { field: 'revenue' }, color: { field: 'year' } }, SALES, { region: 'Region', revenue: 'Amount', year: 'Year' })); expect(fig.layout.barmode).toBe('group'); diff --git a/packages/flint-js/tests/point-size.test.ts b/packages/flint-js/tests/point-size.test.ts index 92bc36d2..4f7a8d9b 100644 --- a/packages/flint-js/tests/point-size.test.ts +++ b/packages/flint-js/tests/point-size.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { describe, it, expect } from 'vitest'; -import { assembleVegaLite } from '../src'; +import { assemblePlotly, assembleVegaLite } from '../src'; import { THEME_PRESETS } from '../src/core/theme/presets'; /** @@ -43,6 +43,26 @@ const drawnSize = (spec: { mark?: unknown; config?: Record (THEME_PRESETS[house].spec as { marks?: { point?: { size?: number } } }).marks?.point?.size; +const radarInput = (house: string) => ({ + data: { + values: [ + { Metric: 'Reach', Group: 'A', Value: 80 }, + { Metric: 'Trust', Group: 'A', Value: 55 }, + { Metric: 'Value', Group: 'A', Value: 70 }, + { Metric: 'Reach', Group: 'B', Value: 60 }, + { Metric: 'Trust', Group: 'B', Value: 85 }, + { Metric: 'Value', Group: 'B', Value: 45 }, + ], + }, + semantic_types: { Metric: 'Category', Group: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Metric', y: 'Value', color: 'Group' }, + baseSize: { width: 420, height: 360 }, + }, + theme_spec: THEME_PRESETS[house].spec, +} as never); + describe('point size', () => { it('every house says how big its dots are', () => { // Silence is not a style. A house that never names a size inherits @@ -109,4 +129,30 @@ describe('point size', () => { expect(line.config?.circle?.size).toBe(declaredSize('mckinsey')); expect(drawnSize(scatter(rows, 'mckinsey'))).toBeLessThan(declaredSize('mckinsey')!); }); + + it('a supporting radar vertex stays secondary-sized in both backends', () => { + // Pop's 180px² point is deliberately loud when the dot itself is the + // reading. On a radar it merely confirms a polygon vertex; carrying + // 180px² across would cover the grid and adjacent paths. + expect(declaredSize('pop')).toBe(180); + + const vl = assembleVegaLite(radarInput('pop')) as any; + const vertices = vl.layer.find((layer: any) => layer.name === 'radar-secondary-vertices'); + const spokes = vl.layer.find((layer: any) => layer.name === 'radar-grid-spokes'); + const rings = vl.layer.find((layer: any) => layer.name === 'radar-grid-rings'); + expect(vertices.mark.size).toBe(25); + expect(spokes.mark.strokeWidth).toBeLessThanOrEqual(1); + expect(rings.mark.strokeWidth).toBeLessThanOrEqual(1); + + const plotly = assemblePlotly(radarInput('pop')) as any; + for (const trace of plotly.data) { + // Plotly states marker size as a whole-pixel diameter; 25px² + // converts to 5.64px and is rounded to 6. + expect(trace.marker.size).toBe(6); + } + expect(plotly.layout.polar.radialaxis.showgrid).toBe(true); + expect(plotly.layout.polar.angularaxis.showgrid).toBe(true); + expect(plotly.layout.polar.radialaxis.gridwidth).toBeLessThanOrEqual(1); + expect(plotly.layout.polar.angularaxis.gridwidth).toBeLessThanOrEqual(1); + }); }); diff --git a/packages/flint-js/tests/sizing-ceiling.test.ts b/packages/flint-js/tests/sizing-ceiling.test.ts index 14d33b3d..5fdb6b66 100644 --- a/packages/flint-js/tests/sizing-ceiling.test.ts +++ b/packages/flint-js/tests/sizing-ceiling.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { assembleVegaLite } from '../src'; import { deriveStretchCaps, resolveStretchCaps, resolveBaseSize } from '../src/core/compute-layout'; +import { computeAxisStep } from '../src/core/decisions'; /** * Regression coverage for the `baseSize` (target) + `canvasSize` (hard ceiling) @@ -18,6 +19,99 @@ import { deriveStretchCaps, resolveStretchCaps, resolveBaseSize } from '../src/c const BASE = { width: 400, height: 320 }; +describe('bandStepFit (base pitch ↔ available span)', () => { + const decision = (bandStepFit: number) => computeAxisStep(4, 0, 400, { + elasticity: 0.5, + maxStretch: 1.5, + defaultStepSize: 20, + minStep: 6, + bandStepFit, + }); + + it('selects toward available capacity, then applies the elastic budget', () => { + expect(decision(0).step).toBe(20); + expect(decision(0.5).step).toBe(60); + expect(decision(1).step).toBe(100); + }); + + it('leaves fit inert under pressure and applies normal base-pitch elasticity', () => { + const dense = (bandStepFit: number) => computeAxisStep(10, 0, 100, { + elasticity: 0.5, + maxStretch: 1.5, + defaultStepSize: 20, + minStep: 6, + bandStepFit, + }); + expect(dense(0).step).toBe(14); + expect(dense(0.5).step).toBe(14); + expect(dense(1).step).toBe(14); + expect(dense(0).budget).toBeLessThanOrEqual(150); + }); + + it('survives assembly before the overall canvas budget clamps it', () => { + const values = Array.from({ length: 4 }, (_, index) => ({ + Category: String.fromCharCode(65 + index), + Value: index + 1, + })); + const compile = (bandStepFit: number) => assembleVegaLite({ + data: { values }, + semantic_types: { Category: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Category', y: 'Value' }, + baseSize: { width: 400, height: 320 }, + canvasSize: { width: 400, height: 320 }, + }, + options: { defaultBandSize: 20, bandStepFit }, + } as never) as { width: { step: number } }; + + const fixed = compile(0).width.step; + const blended = compile(0.5).width.step; + const filled = compile(1).width.step; + expect(fixed).toBeGreaterThanOrEqual(20); + expect(blended).toBeGreaterThan(fixed); + expect(filled).toBeGreaterThan(blended); + expect(filled * values.length).toBeLessThanOrEqual(400); + }); + + it('does not interfere when both axes are banded cells', () => { + const values = ['A', 'B', 'C'].flatMap(x => ['U', 'V'].map((y, index) => ({ + x, y, value: index + 1, + }))); + const compile = (bandStepFit: number) => assembleVegaLite({ + data: { values }, + semantic_types: { x: 'Category', y: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Heatmap', + encodings: { x: 'x', y: 'y', color: 'value' }, + baseSize: { width: 400, height: 320 }, + }, + options: { bandStepFit }, + } as never) as { width: { step: number }; height: { step: number } }; + + expect(compile(1).width.step).toBe(compile(0).width.step); + expect(compile(1).height.step).toBe(compile(0).height.step); + }); + + it('does not change fully continuous axes', () => { + const compile = (bandStepFit: number) => assembleVegaLite({ + data: { values: [{ x: 1, y: 3 }, { x: 2, y: 5 }, { x: 4, y: 2 }] }, + semantic_types: { x: 'Quantity', y: 'Quantity' }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'x', y: 'y' }, + baseSize: { width: 400, height: 320 }, + }, + options: { bandStepFit }, + } as never) as { width: number; height: number; _width: number; _height: number }; + + const fixed = compile(0); + const filled = compile(1); + expect([filled.width, filled.height, filled._width, filled._height]) + .toEqual([fixed.width, fixed.height, fixed._width, fixed._height]); + }); +}); + describe('deriveStretchCaps (canvasSize → per-dimension βx/βy)', () => { it('falls back to maxStretch (default 1.5) when no ceiling is given', () => { expect(deriveStretchCaps(BASE, undefined, {})).toEqual({ diff --git a/packages/flint-js/tests/theme-geometry.test.ts b/packages/flint-js/tests/theme-geometry.test.ts new file mode 100644 index 00000000..4373b5b3 --- /dev/null +++ b/packages/flint-js/tests/theme-geometry.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite } from '../src'; +import { resolveGeometry } from '../src/core/theme/ground'; +import type { ThemeSpec } from '../src/core/theme/types'; + +/** + * Geometry is the one part of a theme that a chart type is allowed to answer + * for itself. The lab measured why: inside a single language, line width and + * dot treatment move with the chart — NYT dots every reading of a connected + * scatter and none of the Keeling curve — so a flat profile cannot state a + * house honestly, and one spec per language is the rule being tested. + */ + +const LINE_DATA = [ + { Year: 2019, Share: 12, Country: 'Norway' }, + { Year: 2020, Share: 18, Country: 'Norway' }, + { Year: 2021, Share: 25, Country: 'Norway' }, + { Year: 2019, Share: 4, Country: 'France' }, + { Year: 2020, Share: 7, Country: 'France' }, + { Year: 2021, Share: 11, Country: 'France' }, +]; + +const BAR_DATA = [ + { Cause: 'Heart disease', Deaths: 695 }, + { Cause: 'Cancer', Deaths: 605 }, + { Cause: 'Injury', Deaths: 224 }, +]; + +function line(theme?: ThemeSpec, chartType = 'Line Chart'): any { + return assembleVegaLite({ + data: { values: LINE_DATA }, + semantic_types: { Year: 'Year', Share: 'Percentage', Country: 'Country' }, + chart_spec: { + chartType, + encodings: { x: { field: 'Year' }, y: { field: 'Share' }, color: { field: 'Country' } }, + baseSize: { width: 400, height: 300 }, + }, + ...(theme ? { theme_spec: theme } : {}), + } as any); +} + +function bar(theme?: ThemeSpec): any { + return assembleVegaLite({ + data: { values: BAR_DATA }, + semantic_types: { Cause: 'Category', Deaths: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'Cause' }, y: { field: 'Deaths' } }, + baseSize: { width: 400, height: 300 }, + }, + ...(theme ? { theme_spec: theme } : {}), + } as any); +} + +const reportOf = (spec: any) => JSON.stringify(spec._theme?.report ?? []); + +describe('theme geometry — the common profile', () => { it('draws lines at the width the house states', () => { + const spec = line({ id: 'h', geometry: { line: { width: 3.5 } } }); + expect(spec.config.line.strokeWidth).toBe(3.5); + }); + + it('carries a dot at every reading when the house asks for one', () => { + const spec = line({ id: 'h', geometry: { point: { presence: 'full', size: 40 } } }); + expect(spec.config.line.point).toBeTruthy(); + expect(spec.config.line.point.size).toBe(40); + }); + + it('leaves the line bare where the house omits its dots', () => { + const spec = line({ id: 'h', geometry: { point: { presence: 'omit' } } }); + expect(spec.config.line?.point).toBeFalsy(); + }); + + it('fills the band to the fraction the house states', () => { + const wide = bar({ id: 'h', geometry: { band: { fraction: 0.95 } } }); + const narrow = bar({ id: 'h', geometry: { band: { fraction: 0.4 } } }); + const padding = (spec: any) => spec.encoding.x.scale.paddingInner; + expect(padding(wide)).toBeLessThan(padding(narrow)); + }); + + it('rounds the value end of a bar to the house radius', () => { + const spec = bar({ id: 'h', geometry: { band: { cornerRadius: 6 } } }); + expect(spec.config.bar.cornerRadiusEnd).toBe(6); + }); +}); + +describe('theme geometry — conditional on what the compiler already knows', () => { + /** + * The lab refused a per-chart-type key and pointed here instead: NYT, the + * Economist and Power BI each thin their line to exactly 2px on the + * four-series chart and run thicker on a lone series, and Power BI drops + * its dots only where the chart is faceted into sixteen panels. + */ + it('thins the line where several series share the plot', () => { + const theme: ThemeSpec = { + id: 'h', + geometry: { line: { width: 2.4 }, point: { presence: 'full', size: 45 } }, + variants: [{ + when: { seriesCount: { gte: 2 } }, + then: { geometry: { line: { width: 2 }, point: { size: 26 } } }, + because: 'several lines in one plot crowd; a thinner stroke keeps them apart', + }], + }; + const many = line(theme); + expect(many.config.line.strokeWidth).toBe(2); + expect(many.config.line.point.size).toBe(26); + }); + + it('keeps the standing profile where the guard does not hold', () => { + const theme: ThemeSpec = { + id: 'h', + geometry: { line: { width: 2.4 } }, + variants: [{ + when: { seriesCount: { gte: 2 } }, + then: { geometry: { line: { width: 2 } } }, + because: 'crowding', + }], + }; + const single = assembleVegaLite({ + data: { values: LINE_DATA.filter((r) => r.Country === 'Norway') }, + semantic_types: { Year: 'Year', Share: 'Percentage' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: { field: 'Year' }, y: { field: 'Share' } }, + baseSize: { width: 400, height: 300 }, + }, + theme_spec: theme, + } as any); + expect(single.config.line.strokeWidth).toBe(2.4); + }); +}); + +describe('theme geometry — what a chart cannot hear', () => { + it('drops geometry the template does not build, and says so', () => { + const { geometry, report } = resolveGeometry( + { id: 'h', geometry: { arc: { gap: 4 }, line: { width: 2 } } }, + 'Line Chart', + ['line', 'point'], + ); + expect(geometry.arc).toBeUndefined(); + expect(geometry.line?.width).toBe(2); + expect(JSON.stringify(report)).toContain('builds no arc'); + }); + + it('keeps the whole profile for a template that has not declared its shapes', () => { + const { geometry } = resolveGeometry( + { id: 'h', geometry: { arc: { gap: 4 } } }, + 'Some Chart', + undefined, + ); + expect(geometry.arc?.gap).toBe(4); + }); + + it('reports the drop on the assembled chart, not only in the resolver', () => { + const spec = bar({ id: 'h', geometry: { arc: { gap: 4 }, band: { fraction: 0.6 } } }); + expect(reportOf(spec)).toContain('builds no arc'); + }); +}); + +describe('theme geometry — compatibility with `marks`', () => { + it('reads a marks-only theme as its geometry profile', () => { + const { geometry } = resolveGeometry( + { id: 'h', marks: { strokeWeight: 2.5, bandFraction: 0.7, point: { presence: 'full', size: 30 } } }, + 'Line Chart', + ['line', 'point'], + ); + expect(geometry.line?.width).toBe(2.5); + expect(geometry.point?.size).toBe(30); + }); + + it('lets geometry override the marks it was derived from', () => { + const spec = line({ + id: 'h', + marks: { strokeWeight: 1 }, + geometry: { line: { width: 5 } }, + }); + expect(spec.config.line.strokeWidth).toBe(5); + }); + + it('leaves a chart with no theme exactly as it was', () => { + expect(JSON.stringify(line())).toBe(JSON.stringify(line())); + expect(line().config?.line?.strokeWidth).toBeUndefined(); + }); +}); diff --git a/packages/flint-js/tests/theme-plotly.test.ts b/packages/flint-js/tests/theme-plotly.test.ts new file mode 100644 index 00000000..8c97504e --- /dev/null +++ b/packages/flint-js/tests/theme-plotly.test.ts @@ -0,0 +1,804 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assemblePlotly } from '../src'; +import type { ThemeSpec } from '../src/core/theme/types'; +import { THEME_PRESETS } from '../src/core/theme/presets'; + +/** + * The Plotly realizer writes the same backend-neutral `DesignDecisions` onto a + * `{data, layout}` figure that the Vega-Lite one writes onto a spec. The cases + * below are the ones the audit sweeps found by eye and that a rendered contact + * sheet is a slow way to check twice. + */ + +const theme = (extra: Partial = {}): ThemeSpec => ({ + id: 'house', + label: 'House', + ink: { + surface: { canvas: '#ffffff', plot: '#ffffff' }, + text: { primary: '#111111' }, + series: { single: '#cc0000', categorical: ['#cc0000', '#0044cc', '#118844'] }, + }, + ...extra, +} as ThemeSpec); + +const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function bullet(spec: ThemeSpec = theme()): any { + return assemblePlotly({ + data: { + values: [ + { Store: 'Newark', Revenue: 489, Goal: 540 }, + { Store: 'Dallas', Revenue: 707, Goal: 650 }, + { Store: 'Austin', Revenue: 845, Goal: 720 }, + ], + }, + semantic_types: { Store: 'Category', Revenue: 'Amount', Goal: 'Amount' }, + chart_spec: { + chartType: 'Bullet Chart', + title: 'Revenue against target', + encodings: { y: 'Store', x: 'Revenue', goal: 'Goal' }, + baseSize: { width: 480, height: 300 }, + }, + theme_spec: spec, + } as any) as any; +} + +function monthlyLine(spec: ThemeSpec, width = 480): any { + return assemblePlotly({ + data: { + values: months.map((m, i) => ({ Month: m, Revenue: 100 + i * 7 })), + }, + semantic_types: { Month: 'Category', Revenue: 'Amount' }, + chart_spec: { + chartType: 'Line Chart', + title: 'Monthly revenue', + encodings: { x: 'Month', y: 'Revenue' }, + baseSize: { width, height: 300 }, + }, + theme_spec: spec, + } as any) as any; +} + +function denseLine(spec: ThemeSpec, showPoints?: boolean): any { + return assemblePlotly({ + data: { + values: Array.from({ length: 100 }, (_, i) => ({ x: i, y: Math.sin(i / 8) })), + }, + semantic_types: { x: 'Quantity', y: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'x', y: 'y' }, + baseSize: { width: 400, height: 300 }, + ...(showPoints == null ? {} : { chartProperties: { showPoints } }), + }, + theme_spec: spec, + } as any) as any; +} + +const traceNamed = (fig: any, name: string) => + (fig.data ?? []).find((t: any) => t.name === name); + +describe('semantic geometry survives house styling', () => { + it('keeps sparklines free of theme-preferred dots', () => { + const fig = assemblePlotly({ + data: { + values: months.map((month, i) => ({ month, value: 100 + i })), + }, + semantic_types: { month: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Sparkline', + encodings: { x: 'month', y: 'value' }, + baseSize: { width: 400, height: 120 }, + }, + theme_spec: THEME_PRESETS.pop.spec, + } as any) as any; + expect(fig.data + .filter((trace: any) => trace.type === 'scatter') + .every((trace: any) => !String(trace.mode).includes('markers'))) + .toBe(true); + }); + + it('suppresses dense theme-default line dots but preserves an explicit request', () => { + const themedDefault = denseLine(THEME_PRESETS.nyt.spec); + expect(String(themedDefault.data[0].mode)).not.toContain('markers'); + expect(themedDefault._theme.report.some((entry: any) => /too dense/.test(entry.message))).toBe(true); + + const explicit = denseLine(THEME_PRESETS.nyt.spec, true); + expect(String(explicit.data[0].mode)).toContain('markers'); + }); + + it('preserves an explicit request to hide points from a dot-preferring house', () => { + const fig = denseLine(THEME_PRESETS.pop.spec, false); + expect(String(fig.data[0].mode)).not.toContain('markers'); + }); + + it('retains theme-default dots when line observations have enough room', () => { + const fig = monthlyLine(THEME_PRESETS.nyt.spec); + expect(String(fig.data[0].mode)).toContain('markers'); + }); + + it('measures sparse categorical dots from observed positions rather than null placeholders', () => { + const values = Array.from({ length: 100 }, (_, i) => ({ + category: `C${i}`, + value: i, + series: i % 10 === 0 ? 'Sparse' : 'Dense', + })); + const fig = assemblePlotly({ + data: { values }, + semantic_types: { category: 'Category', value: 'Quantity', series: 'Category' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'category', y: 'value', color: 'series' }, + baseSize: { width: 600, height: 300 }, + }, + theme_spec: THEME_PRESETS.nyt.spec, + } as any) as any; + expect(String(traceNamed(fig, 'Sparse').mode)).toContain('markers'); + expect(String(traceNamed(fig, 'Dense').mode)).not.toContain('markers'); + }); + + it('does not count non-renderable x values toward line-dot density', () => { + const values = [ + { x: 0, y: 1 }, + { x: 1, y: 2 }, + { x: 2, y: 3 }, + ...Array.from({ length: 13 }, (_, i) => ({ x: null, y: i + 4 })), + ]; + const fig = assemblePlotly({ + data: { values }, + semantic_types: { x: 'Quantity', y: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'x', y: 'y' }, + baseSize: { width: 400, height: 300 }, + }, + theme_spec: THEME_PRESETS.pop.spec, + } as any) as any; + expect(String(fig.data[0].mode)).toContain('markers'); + }); + + it('keeps an ECDF stepped when the house prefers curved lines', () => { + const fig = assemblePlotly({ + data: { values: [{ score: 1 }, { score: 2 }, { score: 3 }] }, + semantic_types: { score: 'Quantity' }, + chart_spec: { + chartType: 'ECDF Plot', + encodings: { x: 'score' }, + baseSize: { width: 400, height: 300 }, + }, + theme_spec: THEME_PRESETS.pop, + } as any) as any; + expect(['hv', 'vh', 'hvh']).toContain(fig.data[0].line.shape); + }); + + it('prints normalized stacked values as shares for every number format', () => { + const fig = assemblePlotly({ + data: { + values: [ + { category: 'A', series: 'One', value: 20 }, + { category: 'A', series: 'Two', value: 80 }, + ], + }, + semantic_types: { category: 'Category', series: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'category', y: 'value', color: 'series' }, + chartProperties: { stackMode: 'normalize' }, + baseSize: { width: 400, height: 300 }, + }, + theme_spec: theme({ dataLabels: { show: 'always', format: ',.0f' } } as any), + } as any) as any; + expect(fig.data[0].customdata).toEqual([20]); + expect(fig.data[1].customdata).toEqual([80]); + expect(JSON.stringify(fig.data[0].texttemplate)).toContain('customdata'); + expect(JSON.stringify(fig.data[0].texttemplate)).toContain('%'); + }); + + it('matches Plotly algebraic normalization for mixed-sign stacks', () => { + const fig = assemblePlotly({ + data: { + values: [ + { category: 'A', series: 'Positive', value: 20 }, + { category: 'A', series: 'Negative', value: -10 }, + { category: 'B', series: 'Positive', value: 10 }, + { category: 'B', series: 'Negative', value: -20 }, + ], + }, + semantic_types: { category: 'Category', series: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'category', y: 'value', color: 'series' }, + chartProperties: { stackMode: 'normalize' }, + baseSize: { width: 400, height: 300 }, + }, + theme_spec: theme({ dataLabels: { show: 'always', format: '.0f' } } as any), + } as any) as any; + expect(fig.data[0].customdata).toEqual([200, 100]); + expect(fig.data[1].customdata).toEqual([-100, -200]); + }); + + it('normalizes printed stack values within each facet', () => { + const fig = assemblePlotly({ + data: { + values: [ + { category: 'A', series: 'One', value: 20, panel: 'Left' }, + { category: 'A', series: 'Two', value: 80, panel: 'Left' }, + { category: 'A', series: 'One', value: 30, panel: 'Right' }, + { category: 'A', series: 'Two', value: 70, panel: 'Right' }, + ], + }, + semantic_types: { + category: 'Category', series: 'Category', value: 'Quantity', panel: 'Category', + }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'category', y: 'value', color: 'series', column: 'panel' }, + chartProperties: { stackMode: 'normalize' }, + baseSize: { width: 500, height: 300 }, + }, + theme_spec: theme({ dataLabels: { show: 'always', format: '.0f' } } as any), + } as any) as any; + expect(fig.data.map((trace: any) => trace.customdata)).toEqual([ + [20], [80], [30], [70], + ]); + }); + + it('thins labels on a dense categorical axis without dropping bars', () => { + const values = Array.from({ length: 100 }, (_v, i) => ({ + category: `Page ${i + 1}`, + value: i + 1, + })); + const fig = assemblePlotly({ + data: { values }, + semantic_types: { category: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'category', y: 'value' }, + baseSize: { width: 480, height: 300 }, + }, + theme_spec: theme(), + } as any) as any; + expect(fig.data[0].x).toHaveLength(100); + expect(fig.layout.xaxis.tickvals.length).toBeLessThan(100); + }); + + it('factors color and dash into separate forecast legend dimensions', () => { + const values = ['A', 'B', 'C'].flatMap((product, productIndex) => [ + { x: 1, y: 10 + productIndex, product, state: 'Actual' }, + { x: 2, y: 12 + productIndex, product, state: 'Actual' }, + { x: 2, y: 12 + productIndex, product, state: 'Forecast' }, + { x: 3, y: 15 + productIndex, product, state: 'Forecast' }, + ]); + const fig = assemblePlotly({ + data: { values }, + semantic_types: { x: 'Quantity', y: 'Quantity', product: 'Category', state: 'Category' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'x', y: 'y', color: 'product', strokeDash: 'state' }, + baseSize: { width: 500, height: 300 }, + }, + theme_spec: theme({ + legend: { show: 'always', placement: ['seriesEnd', 'right'] }, + } as any), + } as any) as any; + const actual = fig.data.filter((trace: any) => + trace._colorLegendValue && trace._dashLegendValue); + const proxies = fig.data.filter((trace: any) => + trace._themeRole === 'factored-line-legend-proxy'); + expect(actual).toHaveLength(6); + expect(actual.every((trace: any) => trace.showlegend === false)).toBe(true); + expect(proxies.map((trace: any) => trace.name)).toEqual([ + 'A', 'B', 'C', 'Actual', 'Forecast', + ]); + expect(actual[0].line.color).toBe(actual[1].line.color); + const annotations = JSON.stringify(fig.layout.annotations ?? []); + expect(annotations).not.toContain('Actual'); + expect(annotations).not.toContain('Forecast'); + }); + + it('places a factored series-end label at the furthest segment', () => { + const values = [ + { x: 2, y: 12, product: 'A', state: 'Forecast' }, + { x: 3, y: 15, product: 'A', state: 'Forecast' }, + { x: 1, y: 10, product: 'A', state: 'Actual' }, + { x: 2, y: 12, product: 'A', state: 'Actual' }, + { x: 2, y: 20, product: 'B', state: 'Forecast' }, + { x: 3, y: 22, product: 'B', state: 'Forecast' }, + { x: 1, y: 18, product: 'B', state: 'Actual' }, + { x: 2, y: 20, product: 'B', state: 'Actual' }, + { x: 2, y: 30, product: 'C', state: 'Forecast' }, + { x: 3, y: 32, product: 'C', state: 'Forecast' }, + { x: 1, y: 28, product: 'C', state: 'Actual' }, + { x: 2, y: 30, product: 'C', state: 'Actual' }, + ]; + const fig = assemblePlotly({ + data: { values }, + semantic_types: { x: 'Quantity', y: 'Quantity', product: 'Category', state: 'Category' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'x', y: 'y', color: 'product', strokeDash: 'state' }, + baseSize: { width: 500, height: 300 }, + }, + theme_spec: theme({ + legend: { show: 'always', placement: ['seriesEnd', 'right'] }, + } as any), + } as any) as any; + expect(fig.layout.annotations.map((annotation: any) => annotation.x)).toEqual([3, 3, 3]); + }); +}); + +describe('furniture is not a series', () => { + it('restates a bullet chart\'s context bands against the house surface', () => { + const dark = bullet(theme({ + ink: { + surface: { canvas: '#111111', plot: '#111111' }, + text: { primary: '#f4f4f4' }, + series: { single: '#4499ff', categorical: ['#4499ff'] }, + }, + } as any)); + const zone = traceNamed(dark, '__zone0'); + expect(zone).toBeTruthy(); + // The template's #e2e2e2 would be a light bar on a dark card. + expect(String(zone.marker.color).toLowerCase()).not.toBe('#e2e2e2'); + expect(zone._role).toBe('context'); + }); + + it('never paints a band or a target tick with the series ink', () => { + const fig = bullet(); + const ink = String(traceNamed(fig, 'value').marker.color?.[0] ?? '').toLowerCase(); + for (const name of ['__zone0', '__zone1', '__zone2']) { + expect(String(traceNamed(fig, name).marker.color).toLowerCase()).not.toBe(ink); + } + const target = traceNamed(fig, 'Target'); + expect(target._role).toBe('reference'); + expect(String(target.marker.line.color).toLowerCase()).toBe('#111111'); + }); + + it('never prints NaN where a series is measured across the page', () => { + const fig = bullet(theme({ dataLabels: { show: 'always' } } as any)); + for (const t of fig.data ?? []) { + if (t.texttemplate == null) continue; + expect(String(t.texttemplate)).not.toContain('%{y'); + } + }); +}); + +describe('a banded axis measured against the room it has', () => { + it('keeps the template\'s turned labels where straight ones will not fit', () => { + const narrow = monthlyLine(theme({ axes: { label: { angle: 0 } } } as any), 260); + // Twelve month names across 260px cannot read straight. + expect(narrow.layout.xaxis.tickangle).not.toBe(0); + expect( + narrow._theme.report.some((r: any) => /keeps its turned labels/.test(r.message)), + ).toBe(true); + }); + + it('lets the house straighten them where there is room', () => { + const wide = monthlyLine(theme({ axes: { label: { angle: 0 } } } as any), 1400); + expect(wide.layout.xaxis.tickangle).toBe(0); + }); +}); + +describe('a polar plot is themed too', () => { + it('holds its radial labels straight and gives them the house type', () => { + const fig = assemblePlotly({ + data: { + values: months.slice(0, 5).flatMap((m, i) => [ + { Nutrient: m, Food: 'Oats', Grams: 10 + i }, + { Nutrient: m, Food: 'Almonds', Grams: 20 - i }, + ]), + }, + semantic_types: { Nutrient: 'Category', Food: 'Category', Grams: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + title: 'Nutrition profile', + encodings: { x: 'Nutrient', y: 'Grams', color: 'Food' }, + baseSize: { width: 380, height: 380 }, + }, + theme_spec: theme(), + } as any) as any; + const radial = fig.layout.polar?.radialaxis; + expect(radial).toBeTruthy(); + expect(radial.tickangle).toBe(0); + expect(fig.layout.polar.bgcolor).toBe('#ffffff'); + }); +}); + +describe('the report says what was approximated', () => { + it('records every realize decision under a path', () => { + const fig = bullet(); + expect(Array.isArray(fig._theme?.report)).toBe(true); + for (const entry of fig._theme.report) { + expect(typeof entry.path).toBe('string'); + expect(entry.path.length).toBeGreaterThan(0); + } + }); +}); + +describe('the figure is sized at what Plotly actually draws', () => { + it('keeps the host sizing hint in step with the layout', () => { + const fig = bullet(); + expect(fig._width).toBe(Math.ceil(fig.layout.width)); + expect(fig._height).toBe(Math.ceil(fig.layout.height)); + expect(Number.isInteger(fig._width)).toBe(true); + }); +}); + +describe('a sparse axis names the values it holds', () => { + const olympics = (): any => assemblePlotly({ + data: { + values: [2012, 2016, 2020, 2024].flatMap((Year, i) => [ + { Year, Nation: 'US', Rank: 1 }, + { Year, Nation: 'China', Rank: 2 + (i % 2) }, + { Year, Nation: 'Japan', Rank: 3 }, + ]), + }, + semantic_types: { Year: 'Year', Nation: 'Category', Rank: 'Quantity' }, + chart_spec: { + chartType: 'Bump Chart', + title: 'Medal-table rank', + encodings: { x: 'Year', y: 'Rank', color: 'Nation' }, + baseSize: { width: 520, height: 320 }, + }, + theme_spec: theme(), + } as any) as any; + + it('pins the four games rather than letting Plotly invent 2025', () => { + const fig = olympics(); + expect(fig.layout.xaxis.tickmode).toBe('array'); + expect(fig.layout.xaxis.tickvals).toHaveLength(4); + expect(fig.layout.xaxis.tickvals.map((v: any) => new Date(v).getUTCFullYear())) + .toEqual([2012, 2016, 2020, 2024]); + }); + + it('leaves a measure axis to its own round numbers', () => { + const fig = assemblePlotly({ + data: { + values: [ + { Year: 1995, Share: 1 }, { Year: 2000, Share: 7 }, + { Year: 2005, Share: 16 }, { Year: 2010, Share: 29 }, + { Year: 2015, Share: 43 }, { Year: 2018, Share: 51 }, + { Year: 2023, Share: 67 }, + ], + }, + semantic_types: { Year: 'Year', Share: 'Percentage' }, + chart_spec: { + chartType: 'Area Chart', + title: 'Share of the world online', + encodings: { x: 'Year', y: 'Share' }, + baseSize: { width: 520, height: 320 }, + }, + theme_spec: theme(), + } as any) as any; + expect(fig.layout.yaxis.tickvals).toBeUndefined(); + // Uneven year steps must not print two labels on top of each other. + const xs: number[] = fig.layout.xaxis.tickvals ?? []; + expect(xs).not.toContain(2018); + }); +}); + +describe('a printed value carries its unit on the right side', () => { + it('leads with a currency sign instead of trailing it', () => { + const fig = assemblePlotly({ + data: { + values: [ + { Group: 'Lowest', Item: 'Housing', Spend: 12800 }, + { Group: 'Lowest', Item: 'Food', Spend: 4600 }, + { Group: 'Highest', Item: 'Housing', Spend: 36600 }, + { Group: 'Highest', Item: 'Food', Spend: 13400 }, + ], + }, + semantic_types: { Group: 'Category', Item: 'Category', Spend: 'Currency' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + title: 'Where the money goes', + encodings: { x: 'Group', y: 'Spend', color: 'Item' }, + baseSize: { width: 560, height: 360 }, + }, + theme_spec: theme({ annotation: { dataLabels: { show: 'all' } } } as any), + } as any) as any; + const templated = (fig.data as any[]).filter((t) => typeof t.texttemplate === 'string'); + for (const t of templated) { + expect(t.texttemplate.endsWith('$')).toBe(false); + } + }); +}); + +describe('a pie does not name every slice twice', () => { + const pie = (labelType: string, themed: boolean): any => assemblePlotly({ + data: { + values: [ + { Vendor: 'Mouse', Share: 25 }, + { Vendor: 'Keyboard', Share: 22 }, + { Vendor: 'Camera', Share: 20 }, + { Vendor: 'Tablet', Share: 18 }, + { Vendor: 'Phone', Share: 15 }, + ], + }, + semantic_types: { Vendor: 'Category', Share: 'Quantity' }, + chart_spec: { + chartType: 'Pie Chart', + title: 'Share by vendor', + encodings: { color: 'Vendor', size: 'Share' }, + chartProperties: { labelType }, + baseSize: { width: 420, height: 360 }, + }, + ...(themed ? { theme_spec: theme() } : {}), + } as any) as any; + + it('omits the default legend when labels identify every slice', () => { + expect(pie('categoryPercent', false).layout.showlegend).toBe(false); + const themed = pie('categoryPercent', true); + expect(themed.layout.showlegend).toBe(false); + expect(themed._theme.report.some((entry: any) => + entry.path === 'legend.show' && entry.message.includes('already named'))).toBe(true); + }); + + it('keeps the legend when annotations contain only percentages', () => { + expect(pie('percent', false).layout.showlegend).toBe(true); + expect(pie('percent', true).layout.showlegend).toBe(true); + }); + + it('wraps only category-percentage pairs that exceed the inline budget', () => { + const fig = assemblePlotly({ + data: { + values: [ + { Vendor: 'Mouse', Share: 60 }, + { Vendor: 'A substantially longer vendor', Share: 40 }, + ], + }, + semantic_types: { Vendor: 'Category', Share: 'Quantity' }, + chart_spec: { + chartType: 'Pie Chart', + encodings: { color: 'Vendor', size: 'Share' }, + baseSize: { width: 420, height: 360 }, + }, + theme_spec: theme(), + } as any) as any; + + expect(fig.data[0].texttemplate).toEqual([ + '%{label} %{percent}', + '%{label}
%{percent}', + ]); + }); +}); + +describe('high-cardinality point color stays truthful', () => { + it('keeps every observation while collapsing only the overflow legend tail', () => { + const values = Array.from({ length: 10 }, (_, i) => ({ + X: i, + Y: i * 2, + Group: `Group ${i + 1}`, + })); + const fig = assemblePlotly({ + data: { values }, + semantic_types: { X: 'Quantity', Y: 'Quantity', Group: 'Category' }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'X', y: 'Y', color: 'Group' }, + baseSize: { width: 420, height: 360 }, + }, + theme_spec: THEME_PRESETS.economist.spec, + } as any) as any; + + const dataTraces = fig.data.filter((trace: any) => + trace._themeRole !== 'overflow-legend-proxy'); + expect(dataTraces).toHaveLength(10); + expect(dataTraces.reduce((sum: number, trace: any) => sum + trace.x.length, 0)).toBe(10); + const proxy = fig.data.find((trace: any) => + trace._themeRole === 'overflow-legend-proxy'); + expect(proxy.name).toBe('Others (4)'); + expect(dataTraces.filter((trace: any) => trace.showlegend !== false).length) + .toBe(6); + }); +}); + +describe('role-based chart furniture stays legible', () => { + it('uses three semantic waterfall keys instead of Plotly trace 0', () => { + const fig = assemblePlotly({ + data: { + values: [ + { Step: 'Start', Amount: 100, Type: 'start' }, + { Step: 'Gain', Amount: 30, Type: 'increase' }, + { Step: 'Loss', Amount: -20, Type: 'decrease' }, + { Step: 'End', Amount: 110, Type: 'end' }, + ], + }, + semantic_types: { Step: 'Category', Amount: 'Quantity', Type: 'Category' }, + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: 'Step', y: 'Amount', color: 'Type' }, + }, + theme_spec: THEME_PRESETS.nyt.spec, + } as any) as any; + const names = fig.data.filter((trace: any) => trace.showlegend).map((trace: any) => trace.name); + expect(names).toEqual(['Total', 'Increase', 'Decrease']); + expect(fig.data[0].showlegend).toBe(false); + }); + + it('caps Pop area edges and radar fills without changing their data', () => { + const area = assemblePlotly({ + data: { + values: [ + { Stage: 'A', Value: 10, Group: 'One' }, + { Stage: 'B', Value: 12, Group: 'One' }, + { Stage: 'A', Value: 8, Group: 'Two' }, + { Stage: 'B', Value: 9, Group: 'Two' }, + ], + }, + semantic_types: { Stage: 'Category', Value: 'Quantity', Group: 'Category' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'Stage', y: 'Value', color: 'Group' }, + }, + theme_spec: THEME_PRESETS.pop.spec, + } as any) as any; + expect(area.data.every((trace: any) => trace.line.width <= 1)).toBe(true); + expect(area.data.every((trace: any) => trace.mode === 'lines')).toBe(true); + + const radar = assemblePlotly({ + data: { + values: ['A', 'B', 'C'].flatMap((Metric, i) => [ + { Metric, Team: 'One', Value: 5 + i }, + { Metric, Team: 'Two', Value: 7 - i }, + ]), + }, + semantic_types: { Metric: 'Category', Team: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Metric', y: 'Value', color: 'Team' }, + }, + theme_spec: THEME_PRESETS.pop.spec, + } as any) as any; + expect(radar.data.every((trace: any) => trace.line.width <= 2.5)).toBe(true); + expect(radar.data.every((trace: any) => /, 0\.16\)$/.test(trace.fillcolor))).toBe(true); + }); + + it('draws one positive scatter baseline instead of a domain plus zero rule', () => { + const fig = assemblePlotly({ + data: { + values: [ + { X: 10, Y: 20 }, + { X: 20, Y: 30 }, + ], + }, + semantic_types: { X: 'Quantity', Y: 'Quantity' }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'X', y: 'Y' }, + }, + theme_spec: THEME_PRESETS.nyt.spec, + } as any) as any; + expect(fig.layout.xaxis.showline).toBe(true); + expect(fig.layout.yaxis.zeroline).toBe(false); + }); +}); + +describe('a radar reads each spoke on its own scale', () => { + it('normalises every metric and writes its ceiling into the label', () => { + const rows = [ + { Nutrient: 'Carbs', Food: 'Oats', Grams: 66 }, + { Nutrient: 'Carbs', Food: 'Almonds', Grams: 22 }, + { Nutrient: 'Sugar', Food: 'Oats', Grams: 1 }, + { Nutrient: 'Sugar', Food: 'Almonds', Grams: 4 }, + { Nutrient: 'Fat', Food: 'Oats', Grams: 7 }, + { Nutrient: 'Fat', Food: 'Almonds', Grams: 50 }, + ]; + const fig = assemblePlotly({ + data: { values: rows }, + semantic_types: { Nutrient: 'Category', Food: 'Category', Grams: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + title: 'Nutrition profile', + encodings: { x: 'Nutrient', y: 'Grams', color: 'Food' }, + baseSize: { width: 420, height: 420 }, + }, + theme_spec: theme(), + } as any) as any; + expect(fig.layout.polar.radialaxis.range).toEqual([0, 1]); + expect(fig.layout.polar.radialaxis.showticklabels).toBe(false); + expect(fig.layout.polar.radialaxis.tickvals).toEqual([0.25, 0.5, 0.75, 1]); + expect(fig.layout.polar.domain.y[1]).toBeGreaterThan(0.9); + const trace = (fig.data as any[])[0]; + expect(trace.theta.some((t: string) => /\(\d/.test(t))).toBe(true); + for (const r of trace.r) expect(r).toBeLessThanOrEqual(1); + }); + + describe('a rose uses polar guides rather than an axis through its wedges', () => { + it('keeps quiet labelled rings and removes the radial ray and ticks', () => { + const fig = assemblePlotly({ + data: { + values: months.map((Month, i) => ({ Month, Rainfall: 20 + i * 11 })), + }, + semantic_types: { Month: 'Category', Rainfall: 'Quantity' }, + chart_spec: { + chartType: 'Rose Chart', + title: 'Rainfall by month', + encodings: { x: 'Month', y: 'Rainfall' }, + baseSize: { width: 420, height: 360 }, + }, + theme_spec: theme(), + } as any) as any; + + const radial = fig.layout.polar.radialaxis; + const angular = fig.layout.polar.angularaxis; + expect(radial.showgrid).toBe(true); + expect(radial.gridwidth).toBeLessThanOrEqual(1); + expect(radial.layer).toBe('below traces'); + expect(radial.showticklabels).toBe(false); + expect(radial.showline).toBe(false); + expect(radial.ticks).toBe(''); + expect(angular.showgrid).toBe(false); + expect(angular.showline).toBe(false); + expect(angular.ticks).toBe(''); + expect(fig._theme.report.some((entry: any) => + entry.path === 'structure.axis.polar')).toBe(true); + const labels = fig.data.find((trace: any) => + trace._themeRole === 'rose-value-labels'); + expect(labels).toBeTruthy(); + expect(labels.text.length).toBeGreaterThan(0); + expect(labels.text.length).toBeLessThan(months.length); + expect(labels.text.at(-1)).toBe('141'); + expect(labels.r.at(-1)).toBeGreaterThan(141); + expect(radial.range[1]).toBeGreaterThan(labels.r.at(-1)); + }); + + it('materializes and themes an implicit default polar subplot', () => { + const fig = assemblePlotly({ + data: { + values: [ + { Direction: 'N', Speed: 20, Site: '2023' }, + { Direction: 'E', Speed: 35, Site: '2023' }, + { Direction: 'N', Speed: 25, Site: '2024' }, + { Direction: 'E', Speed: 45, Site: '2024' }, + ], + }, + semantic_types: { Direction: 'Category', Speed: 'Quantity', Site: 'Category' }, + chart_spec: { + chartType: 'Rose Chart', + title: 'Wind by direction and site', + encodings: { x: 'Direction', y: 'Speed', column: 'Site' }, + baseSize: { width: 420, height: 360 }, + }, + theme_spec: theme(), + } as any) as any; + + expect(fig.layout.polar.radialaxis.showticklabels).toBe(false); + expect(fig.layout.polar.radialaxis.layer).toBe('below traces'); + expect(fig.layout.polar.angularaxis.showgrid).toBe(false); + }); + + it('prints one summed total beyond each stacked rose tip', () => { + const fig = assemblePlotly({ + data: { + values: [ + { Direction: 'N', Season: 'Spring', Speed: 10 }, + { Direction: 'N', Season: 'Winter', Speed: 15 }, + { Direction: 'E', Season: 'Spring', Speed: 12 }, + { Direction: 'E', Season: 'Winter', Speed: 18 }, + ], + }, + semantic_types: { + Direction: 'Category', + Season: 'Category', + Speed: 'Quantity', + }, + chart_spec: { + chartType: 'Rose Chart', + encodings: { x: 'Direction', y: 'Speed', color: 'Season' }, + baseSize: { width: 420, height: 360 }, + }, + theme_spec: theme(), + } as any) as any; + + const labels = fig.data.find((trace: any) => + trace._themeRole === 'rose-value-labels'); + expect(labels.text).toEqual(['25', '30']); + expect(labels.r[0]).toBeGreaterThan(25); + expect(labels.r[1]).toBeGreaterThan(30); + }); + }); +}); diff --git a/packages/flint-js/tests/theme-presets.test.ts b/packages/flint-js/tests/theme-presets.test.ts index 348b110f..29394baa 100644 --- a/packages/flint-js/tests/theme-presets.test.ts +++ b/packages/flint-js/tests/theme-presets.test.ts @@ -399,6 +399,230 @@ describe('theme compileDefaults', () => { const houseSaysNothing = stated(theme({})); expect(housePrefersWide._width).toBe(houseSaysNothing._width); }); + + it('declares an explicit sparse band-fit preference for every house', () => { + const expected = { + cartoon: 0.75, + datawrapper: 1, + economist: 0.55, + mckinsey: 0.35, + nature: 0.2, + nyt: 0.7, + pop: 0.85, + powerbi: 1, + 'powerbi-light': 1, + swiss: 0.5, + }; + + expect(Object.fromEntries(Object.keys(THEME_PRESETS).map((id) => [ + id, + resolveThemeSpec(id)?.layout?.bandStepFit, + ]))).toEqual(expected); + }); + + it('realizes McKinsey bars with substantial marks and airy category rhythm', () => { + const values = Array.from({ length: 10 }, (_, index) => ({ + Category: `Item ${index + 1}`, + Value: 100 - index, + })); + const spec = assembleVegaLite({ + data: { values }, + semantic_types: { Category: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { y: 'Category', x: 'Value' }, + canvasSize: { width: 720, height: 720 }, + }, + theme_spec: 'mckinsey', + } as any) as any; + const bar = (spec.layer ?? []).find((layer: any) => markTypeOf(layer.mark) === 'bar'); + const step = spec.height.step; + expect(step).toBeGreaterThanOrEqual(31); + expect(step).toBeLessThanOrEqual(33); + expect(bar.encoding.y.scale.paddingInner).toBeCloseTo(0.3); + const solidMark = step * (1 - bar.encoding.y.scale.paddingInner); + expect(solidMark).toBeCloseTo(step * 0.7); + expect(solidMark).toBeGreaterThan(24 * 0.7); + }); + + it('lets a caller tune theme band fit without losing theme occupancy', () => { + const values = Array.from({ length: 12 }, (_, index) => ({ + Category: `P${index + 1}`, + Value: index + 1, + })); + const compile = (bandStepFit: number) => assembleVegaLite({ + data: { values }, + semantic_types: { Category: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Category', y: 'Value' }, + baseSize: { width: 440, height: 300 }, + canvasSize: { width: 720, height: 300 }, + }, + theme_spec: 'mckinsey', + options: { bandStepFit }, + } as any) as any; + + const fixed = compile(0); + const filled = compile(1); + const bar = (filled.layer ?? []).find((layer: any) => markTypeOf(layer.mark) === 'bar'); + expect(filled.width.step).toBeGreaterThan(fixed.width.step); + expect(bar.encoding.x.scale.paddingInner).toBeCloseTo(0.3); + }); + + it('applies McKinsey occupancy to a waterfall inherited category scale', () => { + const values = Array.from({ length: 12 }, (_, index) => ({ + Month: `2025-${String(index + 1).padStart(2, '0')}`, + Change: index === 0 ? 400 : index % 3 === 0 ? -120 : 180, + })); + const spec = assembleVegaLite({ + data: { values }, + semantic_types: { Month: 'Category', Change: 'Quantity' }, + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: 'Month', y: 'Change' }, + canvasSize: { width: 720, height: 720 }, + }, + theme_spec: 'mckinsey', + } as any) as any; + const barSpec = assembleVegaLite({ + data: { values }, + semantic_types: { Month: 'Category', Change: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Month', y: 'Change' }, + canvasSize: { width: 720, height: 720 }, + }, + theme_spec: 'mckinsey', + } as any) as any; + expect(spec.encoding.x.scale.paddingInner).toBeCloseTo(0.3); + expect([spec._width, spec._height]).toEqual([barSpec._width, barSpec._height]); + expect(spec.encoding.x.axis?.labelAngle).toBeUndefined(); + }); + + it('keeps bar-chart category labeling when waterfall rebuilds its x encoding', () => { + const values = Array.from({ length: 12 }, (_, index) => ({ + Month: `2025-${String(index + 1).padStart(2, '0')}`, + Change: index === 0 ? 400_000 : index % 3 === 0 ? -120_000 : 180_000, + })); + const compile = (chartType: 'Bar Chart' | 'Waterfall Chart') => assembleVegaLite({ + data: { values }, + semantic_types: { Month: 'Category', Change: 'Quantity' }, + chart_spec: { + chartType, + encodings: { x: 'Month', y: 'Change' }, + baseSize: { width: 440, height: 440 }, + canvasSize: { width: 720, height: 440 }, + }, + theme_spec: 'powerbi', + } as any) as any; + + const bar = compile('Bar Chart'); + const waterfall = compile('Waterfall Chart'); + const categoryX = (node: any): any => { + if (!node || typeof node !== 'object') return undefined; + if (node.encoding?.x?.field === 'Month') return node.encoding.x; + for (const value of Object.values(node)) { + const found = categoryX(value); + if (found) return found; + } + return undefined; + }; + expect(categoryX(waterfall)?.axis).toEqual(categoryX(bar)?.axis); + expect(waterfall.config.axisX).toEqual(bar.config.axisX); + }); + + it('gives a waterfall a category step even where the steps are dates', () => { + // The template draws its steps on a band scale whatever the column + // holds. Sized as a temporal axis instead, the bands get no step at all + // and the house's sparse fit has nothing to widen. + const values = Array.from({ length: 12 }, (_, index) => ({ + period: `2025-${String(index + 1).padStart(2, '0')}`, + newUsers: index === 0 ? 400_000 : index % 3 === 0 ? -120_000 : 180_000, + })); + const compile = (bandStepFit: number) => assembleVegaLite({ + data: { values }, + semantic_types: { period: 'YearMonth', newUsers: 'Profit' }, + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: 'period', y: 'newUsers' }, + canvasSize: { width: 720, height: 720 }, + }, + theme_spec: 'powerbi', + options: { bandStepFit }, + } as any) as any; + + const fixed = compile(0); + const filled = compile(1); + expect(typeof fixed.width?.step).toBe('number'); + expect(filled.width.step).toBeGreaterThan(fixed.width.step); + }); + + it('never prints a house label off a template\'s own working column', () => { + // A waterfall binds its bars to where each step starts, so a house + // label layer reaching for "the measure" finds `__wf_prev_sum` and + // prints the running total under the bar — 0 on the opening step. + const values = Array.from({ length: 12 }, (_, index) => ({ + period: `2025-${String(index + 1).padStart(2, '0')}`, + newUsers: index === 0 ? 400_000 : index % 3 === 0 ? -120_000 : 180_000, + })); + const compile = (chartProperties?: Record) => assembleVegaLite({ + data: { values }, + semantic_types: { period: 'YearMonth', newUsers: 'Profit' }, + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: 'period', y: 'newUsers' }, + canvasSize: { width: 720, height: 720 }, + ...(chartProperties ? { chartProperties } : {}), + }, + theme_spec: 'powerbi-light', + } as any) as any; + + const textFields = (spec: any): string[] => { + const found: string[] = []; + const walk = (node: any): void => { + if (!node || typeof node !== 'object') return; + if (markTypeOf(node.mark) === 'text' && node.encoding?.text?.field) { + found.push(node.encoding.text.field); + } + Object.values(node).forEach(walk); + }; + walk(spec); + return found; + }; + + expect(textFields(compile())).toEqual([]); + // Asked for, the template prints its own — the step and the total. + expect(textFields(compile({ showValueLabels: true })).length).toBeGreaterThan(0); + }); + + it('refuses a house straight angle the category names cannot fit, and keeps it when they can', () => { + const compile = (prefix: string) => assembleVegaLite({ + data: { + values: Array.from({ length: 12 }, (_, index) => ({ + Month: `${prefix}${String(index + 1).padStart(2, '0')}`, + Change: index + 1, + })), + }, + semantic_types: { Month: 'Category', Change: 'Quantity' }, + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: 'Month', y: 'Change' }, + baseSize: { width: 440, height: 440 }, + canvasSize: { width: 720, height: 440 }, + }, + theme_spec: 'powerbi', + } as any) as any; + + // The house asks for straight labels; `2025-01` is wider than its band, + // so the axis must not be left flat — whether it ends up at the layout's + // own -45 or Vega-Lite's turn is a rendering detail, not the guarantee. + const long = compile('2025-'); + expect(long.config.axisX.labelAngle ?? -45).not.toBe(0); + + // `P01` fits, so the house keeps the straight labels it asked for. + expect(compile('P').config.axisX.labelAngle).toBe(0); + }); }); describe('theme annotation.unitsInAxisTitle', () => { @@ -548,7 +772,60 @@ describe('a cell in a grid is a position', () => { expect(label).toBeTruthy(); expect(label.encoding.text.field).toBe('Temp'); expect(label.mark.baseline).toBe('middle'); - expect(label.encoding.color.condition?.test).toContain('Temp'); + expect(label.encoding.color.field).toBe('Temp'); + expect(label.encoding.color.scale.type).toBe('quantize'); + expect(new Set(label.encoding.color.scale.range).size).toBeGreaterThan(1); + }); + + it('chooses the higher-contrast theme ink for each ramp interval', () => { + const spec = heatmap(theme({ + dataLabels: { show: 'always', placement: 'atMark', inkMode: 'contrastWithMark' }, + ink: { + surface: { canvas: '#ffffff' }, + text: { primary: '#111111', inverse: '#ffffff' }, + series: { single: '#333333', sequential: { stops: ['#f4a19a', '#f4a19a'] } }, + }, + } as Partial)); + const label = (spec.layer ?? []).find( + (layer: any) => layer.__themeSynthetic && markTypeOf(layer.mark) === 'text', + ); + expect(new Set(label.encoding.color.scale.range)).toEqual(new Set(['#111111'])); + }); + + it('honours a house that reads signed values with a sequential ramp', () => { + const sequential = ['#eef3f8', '#9db8d2', '#051c2c']; + const spec = heatmap(theme({ + ink: { + surface: { canvas: '#ffffff' }, + series: { + single: '#051c2c', + sequential: { stops: sequential }, + diverging: { stops: ['#2251ff', '#eef3f8', '#b4472e'] }, + selection: { signed: 'sequential' }, + }, + }, + } as Partial)) as any; + expect(spec.encoding.color.scale.range).toEqual(sequential); + }); + + it('keeps sparse grid cells near the fixed readability target on a wide canvas', () => { + const months = Array.from({ length: 12 }, (_, index) => `M${index + 1}`); + const cities = ['Cairo', 'Oslo', 'Seattle', 'Singapore']; + const spec = assembleVegaLite({ + data: { + values: cities.flatMap((City, cityIndex) => + months.map((Month, monthIndex) => ({ City, Month, Temp: cityIndex + monthIndex }))), + }, + semantic_types: { City: 'Category', Month: 'Category', Temp: 'Quantity' }, + chart_spec: { + chartType: 'Heatmap', + encodings: { x: 'Month', y: 'City', color: 'Temp' }, + baseSize: { width: 440, height: 300 }, + }, + } as any) as any; + expect(spec.width.step).toBeGreaterThanOrEqual(28); + expect(spec.width.step).toBeLessThanOrEqual(30); + expect(spec.height.step).toBe(spec.width.step); }); }); diff --git a/packages/flint-js/tests/theme-titles.test.ts b/packages/flint-js/tests/theme-titles.test.ts index 1bf7f0f1..731dc598 100644 --- a/packages/flint-js/tests/theme-titles.test.ts +++ b/packages/flint-js/tests/theme-titles.test.ts @@ -61,17 +61,234 @@ describe('axis titles', () => { expect(axisTitle(spec, 'y')).not.toBeNull(); }); - it('lets a house delegate the naming to its headline', () => { + it('keeps a measure title even when the headline repeats its field name', () => { + const spec = bars(house({ axisTitles: 'whenAmbiguous' }), 'Monthly rainfall'); + expect(axisTitle(spec, 'y')).not.toBeNull(); + }); + + it('does not let an omit preference delegate a numeric ruler to the headline', () => { + const spec = bars(house({ axisTitles: 'omit' }), 'Rainfall, month by month'); + expect(axisTitle(spec, 'y')).not.toBeNull(); + }); + + it('keeps the title when the headline names the subject but not the measure', () => { + // `omit` is a delegation, and this headline never took it up: "wetter" + // is the story, not the quantity, so the axis is the only thing left + // that can say the bars are rainfall. const spec = bars(house({ axisTitles: 'omit' }), 'Wetter than it looks'); - expect(axisTitle(spec, 'y')).toBeNull(); + expect(axisTitle(spec, 'y')).not.toBeNull(); }); - it('puts the title back when there is no headline to delegate to', () => { + it('accepts the measure named by its unit alone', () => { + const spec = assembleVegaLite({ + data: { values: [{ City: 'Oslo', 'Price (USD)': 6.2 }, { City: 'Cairo', 'Price (USD)': 2.1 }] }, + semantic_types: { City: 'City', 'Price (USD)': 'Amount' }, + chart_spec: { + chartType: 'Bar Chart', + title: 'The Big Mac index (price in USD)', + encodings: { x: 'City', y: 'Price (USD)' }, + }, + theme_spec: house({ axisTitles: 'omit' }), + } as any) as any; + expect(axisTitle(spec, 'y')).not.toBeNull(); + }); + + it('keeps the title when there is no headline', () => { const spec = bars(house({ axisTitles: 'omit' })); expect(axisTitle(spec, 'y')).not.toBeNull(); const report = spec._theme.report.map((r: any) => r.path); expect(report).toContain('annotation.axisTitles'); }); + + it('keeps the axis title when the ruler is dropped for printed values', () => { + // The ruler is redundant once every bar carries its number; the title + // is not, because `42` says how much and never says of what. + const spec = bars( + { + ...house({ axisTitles: 'omit' }), + structure: { axis: { measure: { suppressWhenValuesPrinted: true } } }, + dataLabels: { show: 'always' }, + } as ThemeSpec, + 'Wetter than it looks', + ); + const enc = (spec.spec?.encoding ?? spec.encoding ?? spec.layer?.[0]?.encoding ?? {}).y; + expect(spec.config.axisY.labels).toBe(false); + expect(enc?.axis?.title).not.toBeNull(); + expect(axisTitle(spec, 'y')).not.toBeNull(); + expect(JSON.stringify(spec._theme.report)).toContain('the axis title stays'); + }); + + it('keeps a needed measure title on its own axis, headline or not', () => { + const withHeadline = bars(house({ axisTitles: 'whenAmbiguous', axisTitlePlacement: 'flatAboveAxis' }), 'Wetter than it looks'); + expect(axisTitle(withHeadline, 'y')).not.toBeNull(); + expect(withHeadline.title.subtitle).toBeUndefined(); + expect(withHeadline._theme.decisions.axes.y.title.placement).toBe('flatAboveAxis'); + + const bare = bars(house({ axisTitles: 'whenAmbiguous', axisTitlePlacement: 'flatAboveAxis' })); + expect(axisTitle(bare, 'y')).not.toBeNull(); + expect(bare.title).toBeUndefined(); + }); + + it('leaves an authored subtitle untouched and keeps the measure named', () => { + const spec = assembleVegaLite({ + data: { values: MONTHLY }, + semantic_types: { Month: 'Month', Rainfall: 'Amount' }, + chart_spec: { + chartType: 'Bar Chart', + title: 'Wetter than it looks', + subtitle: 'Four unusually wet months in 2025', + encodings: { x: 'Month', y: 'Rainfall' }, + }, + theme_spec: house({ axisTitles: 'whenAmbiguous', axisTitlePlacement: 'flatAboveAxis' }), + } as any) as any; + + expect(axisTitle(spec, 'y')).not.toBeNull(); + // The authored words stand; how they are broken to fit the block is a + // layout matter, so the content is what is asserted. + const subtitle = spec.title.subtitle; + expect([subtitle].flat().join(' ')).toBe('Four unusually wet months in 2025'); + }); + + it('keeps rank bound to its axis even when the headline names rank', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Games: 2012, Country: 'United States', Rank: 1 }, + { Games: 2016, Country: 'United States', Rank: 1 }, + { Games: 2012, Country: 'China', Rank: 2 }, + { Games: 2016, Country: 'China', Rank: 3 }, + ] }, + semantic_types: { Games: 'Year', Country: 'Country', Rank: 'Rank' }, + chart_spec: { + chartType: 'Bump Chart', + title: 'Olympic medal-table rank', + encodings: { x: 'Games', y: 'Rank', color: 'Country' }, + }, + theme_spec: house({ axisTitles: 'whenAmbiguous' }), + } as any) as any; + + expect(axisTitle(spec, 'y')).not.toBeNull(); + }); + + it('moves an authored normalized-share label into the title block', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Institution: 'Congress', Response: 'Some', 'Share (%)': 38 }, + { Institution: 'Congress', Response: 'Not much', 'Share (%)': 62 }, + ] }, + semantic_types: { Institution: 'Category', Response: 'Category', 'Share (%)': 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + title: 'Confidence in US institutions', + encodings: { x: 'Share (%)', y: 'Institution', color: 'Response' }, + chartProperties: { stackMode: 'normalize' }, + }, + theme_spec: house({ axisTitles: 'whenAmbiguous', axisTitlePlacement: 'flatAboveAxis' }), + } as any) as any; + + expect(axisTitle(spec, 'x')).not.toBeNull(); + expect(spec.title.subtitle).toBeUndefined(); + }); + + it('keeps a single share measure named on its own axis', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Institution: 'Congress', Response: 'Some', 'Share (%)': 38 }, + { Institution: 'Congress', Response: 'Not much', 'Share (%)': 62 }, + ] }, + semantic_types: { Institution: 'Category', Response: 'Category', 'Share (%)': 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + title: 'Confidence in US institutions', + encodings: { x: 'Share (%)', y: 'Institution', color: 'Response' }, + chartProperties: { stackMode: 'normalize' }, + }, + theme_spec: THEME_PRESETS.economist.spec, + } as any) as any; + + expect(axisTitle(spec, 'x')).not.toBeNull(); + expect(spec.title.subtitle).toBeUndefined(); + }); + + it.each([ + { width: 300, titleSize: 11 }, + { width: 400, titleSize: 12 }, + ])('keeps two Economist measure titles legible and bound at $width px', ({ width, titleSize }) => { + const spec = assembleVegaLite({ + data: { values: [ + { Year: 1956, 'Miles/person': 3675, 'Gas price': 2.38 }, + { Year: 1957, 'Miles/person': 3706, 'Gas price': 2.40 }, + { Year: 1958, 'Miles/person': 3766, 'Gas price': 2.26 }, + ] }, + semantic_types: { Year: 'Year', 'Miles/person': 'Quantity', 'Gas price': 'Quantity' }, + chart_spec: { + chartType: 'Connected Scatter Plot', + title: 'Driving shifts into reverse', + encodings: { x: 'Miles/person', y: 'Gas price', order: 'Year' }, + baseSize: { width, height: 300 }, + }, + theme_spec: THEME_PRESETS.economist.spec, + } as any) as any; + + expect(axisTitle(spec, 'x')).not.toBeNull(); + expect(axisTitle(spec, 'y')).not.toBeNull(); + expect(spec.title.subtitle).toBeUndefined(); + const enc = (spec.spec?.encoding ?? spec.encoding ?? spec.layer?.[0]?.encoding ?? {}); + // The house seats its ruler on the right, so the title hangs off that + // side and the renderer's own measurement seats it on the outer edge. + expect(enc.y.axis.orient).toBe('right'); + expect(enc.y.axis).toMatchObject({ + titleAngle: 0, + titleAlign: 'right', + titlePadding: 0, + }); + expect(enc.y.axis.titleX).toBeUndefined(); + expect(enc.y.axis.labelPadding).toBeUndefined(); + // The title clears the topmost value rather than sitting on it. + expect(enc.y.axis.titleY).toBeLessThanOrEqual(-16); + expect(spec.config.axisX).toMatchObject({ titleFontSize: titleSize, titlePadding: 8 }); + expect(spec.config.axisY).toMatchObject({ titleFontSize: titleSize, titlePadding: 8 }); + expect(JSON.stringify(spec._theme.report)).toContain('a headline cannot bind them to a quantity'); + }); + + it('keeps life expectancy named when the headline only describes the story', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Country: 'Japan', Sex: 'Male', 'Life expectancy (years)': 81.4 }, + { Country: 'Japan', Sex: 'Female', 'Life expectancy (years)': 87.5 }, + ] }, + semantic_types: { Country: 'Country', Sex: 'Category', 'Life expectancy (years)': 'Quantity' }, + chart_spec: { + chartType: 'Ranged Dot Plot', + title: 'The female–male life gap', + encodings: { x: 'Life expectancy (years)', y: 'Country', color: 'Sex' }, + }, + theme_spec: THEME_PRESETS.economist.spec, + } as any) as any; + + expect(axisTitle(spec, 'x')).not.toBeNull(); + expect(spec.title.subtitle).toBeUndefined(); + }); + + it('keeps rank named on its own axis', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Games: 2012, Country: 'United States', Rank: 1 }, + { Games: 2016, Country: 'United States', Rank: 1 }, + { Games: 2012, Country: 'China', Rank: 2 }, + { Games: 2016, Country: 'China', Rank: 3 }, + ] }, + semantic_types: { Games: 'Year', Country: 'Country', Rank: 'Rank' }, + chart_spec: { + chartType: 'Bump Chart', + title: 'Olympic medal-table rank', + encodings: { x: 'Games', y: 'Rank', color: 'Country' }, + }, + theme_spec: THEME_PRESETS.economist.spec, + } as any) as any; + + expect(axisTitle(spec, 'y')).not.toBeNull(); + expect(spec.title.subtitle).toBeUndefined(); + }); }); describe('title block position', () => { @@ -188,3 +405,108 @@ describe('masthead furniture', () => { expect(spec.title).toBeTruthy(); }); }); + +describe('a headline wider than its block', () => { + const LONG = 'Driving Shifts Into Reverse — miles driven per person against the price of gas, 1956 to 2010'; + const PENGUINS = 'Palmer Penguins — flipper length vs body mass'; + + function titled(headline: string): any { + return assembleVegaLite({ + data: { values: MONTHLY }, + semantic_types: { Month: 'Month', Rainfall: 'Amount' }, + chart_spec: { + chartType: 'Bar Chart', + title: headline, + encodings: { x: 'Month', y: 'Rainfall' }, + }, + theme_spec: THEME_PRESETS.nyt.spec, + } as any) as any; + } + + /** A wide plot, where a headline of ordinary length only just overhangs. */ + function scattered(headline: string, themeId: string): any { + const rows = Array.from({ length: 40 }, (_, i) => ({ + flipper: 178 + (i % 20) * 3, + mass: 3200 + ((i * 137) % 2800), + species: ['Adelie', 'Chinstrap', 'Gentoo'][i % 3], + })); + return assembleVegaLite({ + data: { values: rows }, + semantic_types: { flipper: 'Amount', mass: 'Amount', species: 'Category' }, + chart_spec: { + chartType: 'Scatter Plot', + title: headline, + encodings: { x: 'flipper', y: 'mass', color: 'species' }, + }, + theme_spec: (THEME_PRESETS as any)[themeId].spec, + } as any) as any; + } + + it('leaves a headline that fits alone', () => { + expect(titled('Rain keeps falling').title.text).toBe('Rain keeps falling'); + }); + + it('lets a headline that only overhangs stand, in every house', () => { + for (const id of Object.keys(THEME_PRESETS)) { + const text = scattered(PENGUINS, id).title.text; + expect(Array.isArray(text), `${id} broke a headline it could have carried`).toBe(false); + } + }); + + it('breaks a headline that will not fit even set down a size', () => { + const spec = titled(LONG); + expect(Array.isArray(spec.title.text)).toBe(true); + expect(spec.title.text.length).toBeGreaterThan(1); + // Nothing is lost or reordered in the break. + expect(spec.title.text.join(' ')).toBe(LONG); + }); + + it('breaks over even lines, not one full line and an orphan', () => { + const lines: string[] = scattered(LONG, 'economist').title.text; + expect(lines.length).toBeGreaterThan(1); + const lengths = lines.map((l) => l.length); + expect(Math.min(...lengths)).toBeGreaterThan(Math.max(...lengths) * 0.6); + }); + + it('takes the lines it gains out of the plot, not out of the canvas', () => { + const plot = (s: any) => s.config?.view?.continuousHeight; + expect(plot(titled(LONG))).toBeLessThan(plot(titled('Rain keeps falling'))); + }); +}); + +describe('a closing rule under a banded plot', () => { + /** The synthetic rect a footerRule is drawn as, if one was drawn. */ + function closingRule(spec: any): any { + return (spec.vconcat ?? []).find((child: any) => child.__themeSynthetic && child.mark?.type === 'rect'); + } + + function ruled(): any { + const themeSpec = { + id: 'ruled', + label: 'Ruled', + ink: { surface: { canvas: '#ffffff' }, series: { single: '#333333' } }, + furniture: [{ kind: 'footerRule', anchor: 'bottomLeft', color: '#dcdcdc', height: 1 }], + } as unknown as ThemeSpec; + return assembleVegaLite({ + data: { values: MONTHLY }, + semantic_types: { Month: 'Month', Rainfall: 'Amount' }, + chart_spec: { + chartType: 'Bar Chart', + title: 'Rain keeps falling', + encodings: { x: 'Month', y: 'Rainfall' }, + }, + theme_spec: themeSpec, + } as any) as any; + } + + it('runs the width of the bands, not the width a continuous plot would have taken', () => { + const spec = ruled(); + const rule = closingRule(spec); + expect(rule).toBeTruthy(); + const continuous = spec.config?.view?.continuousWidth; + expect(typeof continuous).toBe('number'); + // Four months at Vega-Lite's own step is nowhere near a continuous plot, + // and a rule drawn at that width would stretch the canvas to meet it. + expect(rule.width).toBeLessThan(continuous); + }); +}); diff --git a/packages/flint-js/tests/value-label-format.test.ts b/packages/flint-js/tests/value-label-format.test.ts index f5bf27df..a57b1831 100644 --- a/packages/flint-js/tests/value-label-format.test.ts +++ b/packages/flint-js/tests/value-label-format.test.ts @@ -216,13 +216,13 @@ describe('value label precision', () => { }); describe('which side of the mark the number sits on', () => { - const signed = (values: number[], horizontal = false) => assembleVegaLite({ + const signed = (values: number[], horizontal = false, width = 700) => assembleVegaLite({ data: { values: values.map((v, i) => ({ cat: `C${i + 1}`, val: v })) }, semantic_types: { cat: 'nominal', val: 'quantitative' }, chart_spec: { chartType: 'Bar Chart', encodings: horizontal ? { y: 'cat', x: 'val' } : { x: 'cat', y: 'val' }, - baseSize: { width: 700, height: 320 }, + baseSize: { width, height: 320 }, chartProperties: { showValueLabels: true }, }, theme_spec: 'economist', @@ -233,14 +233,26 @@ describe('value label precision', () => { it('sends the label below a bar that runs down from zero', () => { // A bar drawn downwards ends at the bottom, so "outside" is below it. - // Placed above, the number lands on top of the bar it labels. - const mark = labelMark(signed([-1234.5, -88, 12, 940, -3])); + // Placed above, the number lands on top of the bar it labels. A narrow + // column keeps the bands too thin to print the number inside. + const spec = signed([-1234.5, -88, 12, 940, -3], false, 300); + expect(spec._theme.decisions.dataLabels.placement).toBe('outsideMark'); + const mark = labelMark(spec); expect(mark.baseline).toEqual({ expr: expect.stringContaining('datum["val"] < 0') }); expect(mark.dy).toEqual({ expr: expect.stringContaining('datum["val"] < 0') }); // Below for a negative, above for a positive — and never the reverse. expect(mark.baseline.expr).toBe(`datum["val"] < 0 ? 'top' : 'bottom'`); }); + it('turns those sides around once the number sits inside the bar', () => { + // Wide bands leave room to print in the mark, and inside is the other + // way up: the number hangs under a rising bar's top and sits over the + // end of one that runs down. + const spec = signed([-1234.5, -88, 12, 940, -3]); + expect(spec._theme.decisions.dataLabels.placement).toBe('atMark'); + expect(labelMark(spec).baseline.expr).toBe(`datum["val"] < 0 ? 'bottom' : 'top'`); + }); + it('flips left and right instead when the bars run sideways', () => { const spec = signed([-1234.5, -88, 12, 940, -3], true); const marks = (spec.layer ?? []) diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index daed293c..af1a890f 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -268,6 +268,7 @@ properties"). Required channels are noted. | `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers`, `dodge` | | `"ECDF Plot"` | x, color, detail, column, row | x = measure; cumulative distribution (step line); prop `showPoints` | | `"Heatmap"` | x, y, color, column, row | color = the measure | +| `"Calendar Heatmap"` | x, color | x = date; color = daily value (summed per day); GitHub-style week × weekday grid | | `"Line Chart"` | x, y, color, strokeDash, detail, opacity, column, row | props `interpolate`, `showPoints` | | `"Sparkline"` | x, y, color, detail, row, column | x + y required; small-multiple mini trend lines, one per series (series from `color` or `detail`); props `interpolate`, `baseline`, `trendWidth` | | `"Bump Chart"` | x, y, color, detail, column, row | rank-over-time lines | @@ -321,7 +322,7 @@ type column. **Backend coverage.** Vega-Lite supports all of the above. Other backends support a subset (verify if targeting a non-VL backend): -- **ECharts** adds: `"Calendar Heatmap"`, `"Gauge"`, +- **ECharts** adds: `"Gauge"`, `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, diff --git a/packages/flint-mcp/package.json b/packages/flint-mcp/package.json index 7dec93e7..efd74d77 100644 --- a/packages/flint-mcp/package.json +++ b/packages/flint-mcp/package.json @@ -1,6 +1,6 @@ { "name": "flint-chart-mcp", - "version": "0.5.0", + "version": "0.5.1", "description": "Model Context Protocol server for Flint — compile, validate, and render semantic chart specs across supported backends.", "keywords": [ "mcp", @@ -68,7 +68,7 @@ "@resvg/resvg-js": "^2.6.2", "chart.js": "^4.4.0", "echarts": "^6.0.0", - "flint-chart": "^0.5.0", + "flint-chart": "^0.5.1", "vega": "^6.0.0", "vega-interpreter": "^2.2.1", "vega-lite": "^6.0.0", diff --git a/packages/flint-py/flint/core/resolve_semantics.py b/packages/flint-py/flint/core/resolve_semantics.py index 653a732f..cb1fed30 100644 --- a/packages/flint-py/flint/core/resolve_semantics.py +++ b/packages/flint-py/flint/core/resolve_semantics.py @@ -45,6 +45,27 @@ def timestamp_to_ms(val: float) -> float: return val * 1000 if val <= MAX_TIMESTAMP_SEC else val +def infer_implicit_semantic_type(field_name: str, values: list[Any]) -> str: + tokenized = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", field_name).lower() + tokens = [token for token in re.split(r"[^a-z0-9]+", tokenized) if token] + if "year" not in tokens: + return "" + + observed = [value for value in values if value is not None and value != ""] + if len({str(value) for value in observed}) <= 1: + return "" + for value in observed: + if isinstance(value, bool): + return "" + try: + numeric = float(value) + except (TypeError, ValueError): + return "" + if not numeric.is_integer() or not 1500 <= numeric <= 2200: + return "" + return "Year" + + def looks_like_date_string(s: str) -> bool: t = s.strip() return bool(re.match(r"^\d|^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)", t, re.IGNORECASE)) @@ -225,10 +246,16 @@ def convert_temporal_data( return data keys = list(data[0].keys()) + field_values = {key: [row.get(key) for row in data] for key in keys} + effective_semantic_types = { + key: to_type_string(semantic_types.get(key)) + or infer_implicit_semantic_type(key, field_values[key]) + for key in keys + } temporal_keys: list[str] = [] for k in keys: - st = to_type_string(semantic_types.get(k)) - vc = infer_vis_category([r.get(k) for r in data]) + st = effective_semantic_types[k] + vc = infer_vis_category(field_values[k]) st_category = get_vis_category(st) if st else None if vc == "temporal" or st_category == "temporal" or st == "Decade": temporal_keys.append(k) @@ -240,7 +267,7 @@ def convert_temporal_data( for r in values: for temporal_key in temporal_keys: val = r.get(temporal_key) - st = to_type_string(semantic_types.get(temporal_key)) + st = effective_semantic_types[temporal_key] if isinstance(val, bool): # JS treats booleans as numbers via +true=1 etc, but they don't reach here in practice @@ -310,12 +337,13 @@ def resolve_channel_semantics( raw_annotation = semantic_types.get(field_name) if isinstance(raw_annotation, str): - semantic_type = raw_annotation or "" + supplied_semantic_type = raw_annotation or "" elif isinstance(raw_annotation, dict): - semantic_type = raw_annotation.get("semanticType") or "" + supplied_semantic_type = raw_annotation.get("semanticType") or "" else: - semantic_type = "" + supplied_semantic_type = "" field_values = [r.get(field_name) for r in data] + semantic_type = supplied_semantic_type or infer_implicit_semantic_type(field_name, field_values) type_decision = resolve_encoding_type( semantic_type, field_values, channel, data, field_name, @@ -336,7 +364,7 @@ def resolve_channel_semantics( if sample_values and all(iso_re.match(str(v).strip()) for v in sample_values): resolved_type = "temporal" - fc = resolve_field_semantics(raw_annotation, field_name, field_values) + fc = resolve_field_semantics(raw_annotation or semantic_type, field_name, field_values) annotation = fc["semanticAnnotation"] tick_constraint = resolve_tick_constraint(annotation["semanticType"], annotation.get("intrinsicDomain")) diff --git a/packages/flint-py/flint/vegalite/instantiate_spec.py b/packages/flint-py/flint/vegalite/instantiate_spec.py index 4ccf0ae2..fc7a567c 100644 --- a/packages/flint-py/flint/vegalite/instantiate_spec.py +++ b/packages/flint-py/flint/vegalite/instantiate_spec.py @@ -163,6 +163,7 @@ def apply_temporal_to_encoding(encoding: dict): if is_temporal: enc["scale"]["domain"] = [_iso_z(min_val - pad), _iso_z(max_val + pad)] else: + enc["scale"]["zero"] = False enc["scale"]["domain"] = [min_val - pad, max_val + pad] # --- Canvas sizing --- diff --git a/packages/flint-py/flint/vegalite/templates/waterfall.py b/packages/flint-py/flint/vegalite/templates/waterfall.py index 5bbec922..52e9956f 100644 --- a/packages/flint-py/flint/vegalite/templates/waterfall.py +++ b/packages/flint-py/flint/vegalite/templates/waterfall.py @@ -1,9 +1,19 @@ """Waterfall Chart template.""" from __future__ import annotations +from .utils import resolve_discrete_type + def _waterfall_declare(cs, table, chart_properties): - return {"axisFlags": {"x": {"banded": True}}} + # The steps are drawn on a band scale whatever the column holds — a month is + # a step here, not a date — so the layout has to size it as a category. + x_cs = (cs or {}).get("x") or {} + return { + "axisFlags": {"x": {"banded": True}}, + "resolvedTypes": { + "x": resolve_discrete_type(x_cs.get("type") or "nominal", x_cs.get("field"), table or []), + }, + } def _waterfall_instantiate(spec, ctx): diff --git a/scripts/band-audit.ts b/scripts/band-audit.ts new file mode 100644 index 00000000..6998b04a --- /dev/null +++ b/scripts/band-audit.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Numeric band-geometry audit over the real-data gallery cases. + * + * For every VL case with a banded axis, reports the category count and the + * pitch each house settles, so an over-expanded sparse axis shows up as a + * number rather than something to spot in a contact sheet. + * + * Run: npx esbuild scripts/band-audit.ts --bundle --platform=node --format=esm \ + * --outfile=scripts/.band.mjs --log-level=error \ + * --alias:flint-chart/test-data=./packages/flint-js/src/test-data/index.ts \ + * --alias:flint-chart=./packages/flint-js/src/index.ts && node scripts/.band.mjs + */ + +import { assembleVegaLite } from '../packages/flint-js/src/index'; +import { THEME_PRESETS } from '../packages/flint-js/src/core/theme/presets'; +import { PREVIEW_CASES } from '../site/src/shared/preview-cases'; + +const THEME_IDS = Object.keys(THEME_PRESETS); + +interface Row { + id: string; + theme: string; + count: number; + step: number; + solid: number; +} + +function paddingInner(node: any): number | undefined { + if (!node || typeof node !== 'object') return undefined; + for (const ch of ['x', 'y']) { + const p = node.encoding?.[ch]?.scale?.paddingInner; + if (typeof p === 'number') return p; + } + for (const v of Object.values(node)) { + const found = paddingInner(v); + if (found != null) return found; + } + return undefined; +} + +const rows: Row[] = []; +const failures: string[] = []; + +for (const c of PREVIEW_CASES) { + for (const theme of THEME_IDS) { + let spec: any; + try { + spec = assembleVegaLite({ + data: { values: c.data }, + semantic_types: c.semantic_types, + chart_spec: { + chartType: c.chartType, + encodings: c.encodings, + ...(c.chartProperties ? { chartProperties: c.chartProperties } : {}), + canvasSize: { width: 560, height: 400 }, + }, + theme_spec: theme, + } as any); + } catch (err) { + failures.push(`${c.id} ${theme}: ${(err as Error).message}`); + continue; + } + const step = typeof spec.width?.step === 'number' ? spec.width.step + : typeof spec.height?.step === 'number' ? spec.height.step + : undefined; + if (step == null) continue; + const field = (c.encodings as any)?.x ?? (c.encodings as any)?.y; + const count = new Set((c.data as any[]).map((r) => r?.[field as string])).size; + const fill = 1 - (paddingInner(spec) ?? 0.2); + rows.push({ id: c.id, theme, count, step, solid: step * fill }); + } +} + +const wide = rows.filter((r) => r.step >= 80).sort((a, b) => b.step - a.step); +console.log(`cases measured: ${new Set(rows.map((r) => r.id)).size}, rows: ${rows.length}`); +if (failures.length) console.log(`\ncompile failures: ${failures.length}\n ${failures.slice(0, 10).join('\n ')}`); + +console.log(`\n--- pitch >= 80px (${wide.length} rows) ---`); +for (const r of wide.slice(0, 30)) { + console.log(` ${r.id.padEnd(26)} ${r.theme.padEnd(14)} n=${String(r.count).padEnd(4)} pitch=${r.step.toFixed(0).padEnd(5)} solid=${r.solid.toFixed(0)}`); +} + +console.log('\n--- per-theme max pitch ---'); +for (const theme of THEME_IDS) { + const t = rows.filter((r) => r.theme === theme); + if (!t.length) continue; + const max = t.reduce((a, b) => (b.step > a.step ? b : a)); + const median = [...t].sort((a, b) => a.step - b.step)[Math.floor(t.length / 2)]; + console.log(` ${theme.padEnd(14)} median=${median.step.toFixed(0).padEnd(5)} max=${max.step.toFixed(0).padEnd(5)} (${max.id}, n=${max.count})`); +} diff --git a/scripts/plotly-sheet.ts b/scripts/plotly-sheet.ts new file mode 100644 index 00000000..05e0cc9e --- /dev/null +++ b/scripts/plotly-sheet.ts @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Offline Plotly renderer for the theme audits. + * + * Vega-Lite compiles and renders in Node; Plotly does not — it is a browser + * library that measures text with the DOM. So the contact sheets are drawn by + * the real thing: a headless Chrome with `plotly.js-dist-min` loaded from + * `node_modules`, one page holding a grid of plots, screenshotted at 2×. + * + * Nothing here knows about themes. It takes figures and gives back PNGs, so + * the same helper serves the lab, the R2 corpus and the real-data sweep. + */ + +import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import puppeteer, { type Browser } from 'puppeteer-core'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLOTLY_JS = resolve(__dirname, '../node_modules/plotly.js-dist-min/plotly.min.js'); + +const CHROME_CANDIDATES = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/google-chrome', + '/usr/bin/chromium-browser', + '/usr/bin/chromium', +]; + +export interface PlotlyPanel { + label: string; + /** `{ data, layout }`, or null when assembly failed. */ + figure: any | null; + /** Shown instead of a plot when the figure is missing. */ + error?: string; +} + +export interface PlotlySheet { + /** Output file stem — written as `/.png`. */ + name: string; + heading: string; + panels: PlotlyPanel[]; + cols?: number; +} + +function chromePath(): string { + const found = CHROME_CANDIDATES.find((p) => existsSync(p)); + if (!found) { + throw new Error( + 'No Chrome/Edge/Chromium found. Set one of:\n ' + CHROME_CANDIDATES.join('\n '), + ); + } + return found; +} + +/** The size a figure asks for, with Plotly's own defaults as the floor. */ +function sizeOf(figure: any): { width: number; height: number } { + const w = Number(figure?.layout?.width) || Number(figure?._width) || 420; + const h = Number(figure?.layout?.height) || Number(figure?._height) || 320; + return { width: Math.max(180, w), height: Math.max(150, h) }; +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +/** Drop the `_internal` keys the assemblers hang off the figure. */ +function cleanFigure(figure: any): any { + return { + data: figure?.data ?? [], + layout: figure?.layout ?? {}, + config: { staticPlot: true, displayModeBar: false }, + }; +} + +export class PlotlyRenderer { + private browser: Browser | null = null; + private plotlySrc = ''; + + async open(): Promise { + this.plotlySrc = readFileSync(PLOTLY_JS, 'utf8'); + this.browser = await puppeteer.launch({ + executablePath: chromePath(), + headless: true, + args: ['--no-sandbox', '--disable-gpu', '--font-render-hinting=none'], + }); + } + + async close(): Promise { + await this.browser?.close(); + this.browser = null; + } + + /** Render one contact sheet to `/.png`. Returns any per-panel render errors. */ + async sheet(s: PlotlySheet, outDir: string): Promise { + if (!this.browser) throw new Error('renderer not open'); + mkdirSync(outDir, { recursive: true }); + + const cols = s.cols ?? 4; + const cellW = Math.ceil(Math.max(...s.panels.map((p) => sizeOf(p.figure).width))) + 2; + const cellH = Math.ceil(Math.max(...s.panels.map((p) => sizeOf(p.figure).height))) + 2; + + const page = await this.browser.newPage(); + await page.setViewport({ + width: cols * (cellW + 10) + 20, + height: Math.max(400, Math.ceil(s.panels.length / cols) * (cellH + 26) + 60), + deviceScaleFactor: 2, + }); + + const cells = s.panels + .map( + (p, i) => ` +
+
${esc(p.label)}
+
${ + p.figure ? '' : `
${esc(p.error ?? 'no figure')}
` + }
+
`, + ) + .join(''); + + await page.setContent( + ` + +

${esc(s.heading)}

${cells}
`, + { waitUntil: 'domcontentloaded' }, + ); + await page.addScriptTag({ content: this.plotlySrc }); + + const errors: string[] = await page.evaluate(async (figs: Array) => { + const out: string[] = []; + for (let i = 0; i < figs.length; i++) { + const f = figs[i]; + if (!f) continue; + try { + await (window as any).Plotly.newPlot(`p${i}`, f.data, f.layout, f.config); + } catch (err) { + out.push(`panel ${i}: ${(err as Error).message}`); + const el = document.getElementById(`p${i}`); + if (el) el.innerHTML = `
RENDER FAILED — ${(err as Error).message}
`; + } + } + return out; + }, s.panels.map((p) => (p.figure ? cleanFigure(p.figure) : null)) as any); + + const el = await page.$('#sheet'); + const png = (await el!.screenshot({ type: 'png' })) as Buffer; + writeFileSync(resolve(outDir, `${s.name}.png`), png); + await page.close(); + return errors; + } +} diff --git a/scripts/theme-audit.ts b/scripts/theme-audit.ts new file mode 100644 index 00000000..c53a592a --- /dev/null +++ b/scripts/theme-audit.ts @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Theme audit: renders each theme-lab case as a three-panel contact sheet + * + * 1 Flint default | 2 hand-authored | 3 ThemeSpec compiled + * + * to `audit-out/theme/..png`. + * + * Two specs can differ everywhere in JSON and render identically, and vice + * versa, so the gap between columns 2 and 3 is only assessable as an image. + * + * Run: npx esbuild scripts/theme-audit.ts --bundle --platform=node \ + * --format=esm --outfile=scripts/.audit.mjs --external:@resvg/resvg-js \ + * --log-level=error && node scripts/.audit.mjs [themeId|caseId ...] + */ + +import { readFileSync, readdirSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { compile } from 'vega-lite'; +import * as vega from 'vega'; +import { Resvg } from '@resvg/resvg-js'; + +import { THEME_PRESETS } from '../packages/flint-js/src/core/theme/presets'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ASSETS = resolve(__dirname, '../site/src/playground/theme-lab-assets'); +const COMPILED = resolve(ASSETS, 'compiled'); +const OUT = resolve(__dirname, '../audit-out/theme'); + +const THEMES = THEME_PRESETS; + +const PANEL_WIDTH = 400; +const GAP = 12; +const LABEL_H = 18; + +function clean(spec: any): any { + const out: any = {}; + for (const [k, v] of Object.entries(spec)) if (!k.startsWith('__')) out[k] = v; + return out; +} + +async function toSvg(spec: any): Promise<{ svg: string; width: number; height: number }> { + const vgSpec = compile(clean(spec)).spec; + const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }); + const svg = await view.toSVG(); + view.finalize(); + const m = /]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"/.exec(svg); + return { svg, width: m ? Number(m[1]) : PANEL_WIDTH, height: m ? Number(m[2]) : 300 }; +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +interface Panel { svg: string; width: number; height: number; label: string; background: string } + +function contactSheet(panels: Panel[]): { svg: string; width: number } { + // Panels can be fractional pixels wide, so round up: the sheet width is also + // the render target, and a fractional target rounds a thin sheet down to zero. + const colW = Math.ceil(Math.max(PANEL_WIDTH, ...panels.map((p) => p.width))); + const bodyH = Math.ceil(Math.max(...panels.map((p) => p.height))); + const totalW = panels.length * colW + (panels.length - 1) * GAP; + const totalH = bodyH + LABEL_H; + + let body = ``; + panels.forEach((p, i) => { + const x = i * (colW + GAP); + body += `${esc(p.label)}`; + body += ``; + body += `${p.svg}`; + }); + const svg = `${body}`; + return { svg, width: totalW }; +} + +function backgroundOf(spec: any): string { + return typeof spec.background === 'string' ? spec.background : '#ffffff'; +} + +async function main(): Promise { + const filters = process.argv.slice(2); + rmSync(OUT, { recursive: true, force: true }); + mkdirSync(OUT, { recursive: true }); + + const pairs: Array<{ id: string; theme: string }> = []; + for (const file of readdirSync(ASSETS)) { + const m = /^(.*)\.([a-z0-9]+)\.json$/.exec(file); + if (!m || !THEMES[m[2]]) continue; + pairs.push({ id: m[1], theme: m[2] }); + } + pairs.sort((a, b) => a.theme.localeCompare(b.theme) || a.id.localeCompare(b.id)); + + const failures: string[] = []; + let written = 0; + for (const { id, theme } of pairs) { + if (filters.length && !filters.some((f) => f === theme || f === id || `${id}.${theme}` === f)) continue; + let flint: any, manual: any, compiled: any; + try { + flint = JSON.parse(readFileSync(resolve(ASSETS, `${id}.flint.json`), 'utf8')); + manual = JSON.parse(readFileSync(resolve(ASSETS, `${id}.${theme}.json`), 'utf8')); + compiled = JSON.parse(readFileSync(resolve(COMPILED, `${id}.${theme}.json`), 'utf8')); + } catch (err) { + failures.push(`${id}.${theme}: missing spec — ${(err as Error).message}`); + continue; + } + + const panels: Panel[] = []; + for (const [spec, label] of [ + [flint, '1 flint'], + [manual, '2 manual'], + [compiled, '3 compiled'], + ] as Array<[any, string]>) { + try { + const r = await toSvg(spec); + panels.push({ ...r, label: `${label} ${id}.${theme}`, background: backgroundOf(spec) }); + } catch (err) { + failures.push(`${id}.${theme} [${label}]: ${(err as Error).message}`); + panels.push({ svg: '', width: PANEL_WIDTH, height: 300, label: `${label} FAILED`, background: '#ffdddd' }); + } + } + + const sheet = contactSheet(panels); + const png = new Resvg(sheet.svg, { fitTo: { mode: 'width', value: sheet.width * 2 } }) + .render() + .asPng(); + writeFileSync(resolve(OUT, `${theme}.${id}.png`), png); + written++; + } + + const reportPath = resolve(COMPILED, '_report.json'); + const reports = JSON.parse(readFileSync(reportPath, 'utf8')) as Record; + const summary = Object.entries(reports) + .filter(([, v]) => v.report.length) + .map(([k, v]) => `${k}\n${v.report.map((r) => ` [${r.stage}] ${r.path} — ${r.message}`).join('\n')}`) + .join('\n'); + writeFileSync(resolve(OUT, '_report.txt'), summary + '\n'); + + console.log(`wrote ${written} contact sheets to ${OUT}`); + if (failures.length) { + console.log(`\n${failures.length} render failures:`); + for (const f of failures) console.log(` ${f}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/theme-holdout.ts b/scripts/theme-holdout.ts new file mode 100644 index 00000000..7f42f139 --- /dev/null +++ b/scripts/theme-holdout.ts @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The held-out set. + * + * The theme lab is the dev set: every gap in `04-experiment.md` was found by + * looking at it, so it cannot say whether the compiler generalises. This script + * draws a sample from `shared/test-data` — chart cases written for other + * purposes entirely, never looked at while the theme compiler was being built — + * and renders each one twice: + * + * 1 Flint default | 2 ThemeSpec compiled + * + * There is no hand-authored column here, and that is the point: the question is + * no longer "how close is the compiler to one human's reading of the house + * rules" but "does the compiler produce a chart worth looking at at all". + * + * Run: npx esbuild scripts/theme-holdout.ts --bundle --platform=node \ + * --format=esm --outfile=scripts/.holdout.mjs --external:@resvg/resvg-js \ + * --log-level=error && node scripts/.holdout.mjs [count] [seed] + */ + +import { readFileSync, readdirSync, existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { compile } from 'vega-lite'; +import * as vega from 'vega'; +import { Resvg } from '@resvg/resvg-js'; + +import { assembleVegaLite, vlGetTemplateDef } from '../packages/flint-js/src/index'; +import { THEME_PRESETS } from '../packages/flint-js/src/core/theme/presets'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CASES = resolve(__dirname, '../shared/test-data'); +const OUT = resolve(__dirname, '../audit-out/holdout'); + +const THEMES = Object.fromEntries( + Object.entries(THEME_PRESETS).map(([id, preset]) => [id, preset.spec]), +) as Record; +const THEME_IDS = Object.keys(THEMES); + +const PANEL_WIDTH = 400; +const GAP = 12; +const LABEL_H = 18; + +/** A small deterministic generator, so a sample can be quoted and re-drawn. */ +function rng(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 0x100000000; + }; +} + +function stripInternal(node: any): void { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { node.forEach(stripInternal); return; } + for (const key of Object.keys(node)) { + if (/^_[^_]/.test(key)) delete node[key]; + else stripInternal(node[key]); + } +} + +function clean(spec: any): any { + const out: any = {}; + for (const [k, v] of Object.entries(spec)) if (!k.startsWith('__')) out[k] = v; + return out; +} + +async function toSvg(spec: any): Promise<{ svg: string; width: number; height: number }> { + const vgSpec = compile(clean(spec)).spec; + const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }); + const svg = await view.toSVG(); + view.finalize(); + const m = /]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"/.exec(svg); + return { svg, width: m ? Number(m[1]) : PANEL_WIDTH, height: m ? Number(m[2]) : 300 }; +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +interface Panel { svg: string; width: number; height: number; label: string; background: string } + +function contactSheet(panels: Panel[]): { svg: string; width: number } { + const colW = Math.ceil(Math.max(PANEL_WIDTH, ...panels.map((p) => p.width))); + const bodyH = Math.ceil(Math.max(...panels.map((p) => p.height))); + const totalW = panels.length * colW + (panels.length - 1) * GAP; + const totalH = bodyH + LABEL_H; + let body = ``; + panels.forEach((p, i) => { + const x = i * (colW + GAP); + body += `${esc(p.label)}`; + body += ``; + body += `${p.svg}`; + }); + const svg = `${body}`; + return { svg, width: totalW }; +} + +interface Case { slug: string; chartType: string; title: string; input: any } + +function loadCases(): Case[] { + const out: Case[] = []; + for (const slug of readdirSync(CASES)) { + const file = resolve(CASES, slug, 'input.json'); + if (!existsSync(file)) continue; + let raw: any; + try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch { continue; } + const input = raw.input ?? raw; + const chartType = raw.chartType ?? input?.chart_spec?.chartType; + if (!chartType || !input?.chart_spec || !input?.data) continue; + if (!vlGetTemplateDef(chartType)) continue; + const title = raw.title ?? slug; + if (input.chart_spec.title == null) input.chart_spec.title = title; + out.push({ slug, chartType, title, input }); + } + return out; +} + +/** One case per chart type, drawn at random, so the sample spans the vocabulary. */ +function sample(cases: Case[], count: number, seed: number): Case[] { + const rand = rng(seed); + const byType = new Map(); + for (const c of cases) { + const list = byType.get(c.chartType) ?? []; + list.push(c); + byType.set(c.chartType, list); + } + const types = [...byType.keys()].sort(); + for (let i = types.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [types[i], types[j]] = [types[j], types[i]]; + } + return types.slice(0, count).map((t) => { + const list = byType.get(t)!; + return list[Math.floor(rand() * list.length)]; + }); +} + +async function main(): Promise { + const count = Number(process.argv[2] ?? 10); + const seed = Number(process.argv[3] ?? 20260101); + rmSync(OUT, { recursive: true, force: true }); + mkdirSync(OUT, { recursive: true }); + + const picked = sample(loadCases(), count, seed); + const failures: string[] = []; + const manifest: any[] = []; + + for (const [i, c] of picked.entries()) { + const themeId = THEME_IDS[i % THEME_IDS.length]; + let flint: any, themed: any; + try { + flint = assembleVegaLite(structuredClone(c.input)); + themed = assembleVegaLite({ ...structuredClone(c.input), theme_spec: THEMES[themeId] }); + } catch (err) { + failures.push(`${c.slug} [${themeId}]: ${(err as Error).message}`); + continue; + } + const report = themed._theme?.report ?? []; + const warnings = [...(flint._warnings ?? []), ...(themed._warnings ?? [])]; + stripInternal(flint); + stripInternal(themed); + + const panels: Panel[] = []; + for (const [spec, label] of [[flint, '1 flint'], [themed, `2 ${themeId}`]] as Array<[any, string]>) { + try { + const r = await toSvg(spec); + panels.push({ + ...r, + label: `${label} ${c.chartType} · ${c.slug}`, + background: typeof spec.background === 'string' ? spec.background : '#ffffff', + }); + } catch (err) { + failures.push(`${c.slug} [${label}]: ${(err as Error).message}`); + panels.push({ svg: '', width: PANEL_WIDTH, height: 300, label: `${label} FAILED`, background: '#ffdddd' }); + } + } + const sheet = contactSheet(panels); + const png = new Resvg(sheet.svg, { fitTo: { mode: 'width', value: sheet.width * 2 } }).render().asPng(); + const name = `${String(i + 1).padStart(2, '0')}.${themeId}.${c.slug}`; + writeFileSync(resolve(OUT, `${name}.png`), png); + writeFileSync(resolve(OUT, `${name}.compiled.json`), JSON.stringify(themed, null, 2) + '\n'); + manifest.push({ n: i + 1, slug: c.slug, chartType: c.chartType, theme: themeId, report, warnings }); + } + + writeFileSync(resolve(OUT, '_manifest.json'), JSON.stringify(manifest, null, 2) + '\n'); + console.log(`sampled ${picked.length} cases (seed ${seed}) into ${OUT}`); + for (const m of manifest) console.log(` ${m.n}. ${m.theme} · ${m.chartType} · ${m.slug}`); + if (failures.length) { + console.log(`\n${failures.length} failures:`); + for (const f of failures) console.log(` ${f}`); + } +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/theme-plotly.ts b/scripts/theme-plotly.ts new file mode 100644 index 00000000..91184065 --- /dev/null +++ b/scripts/theme-plotly.ts @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Plotly theme lab — contact sheets of one chart under every house. + * + * flint | nyt | economist | swiss | nature | mckinsey | datawrapper | powerbi | … + * + * Written to `audit-out/plotly-/.png`, plus `_report.txt` with every + * ground/realize report and every assembly or render failure. + * + * Three corpora, in the order the Vega-Lite experiment used them: + * --set lab a hand-picked handful of core chart types (start small) + * --set r2 the synthetic R2 corpus (clean cardinalities, wide coverage) + * --set real the real-world datasets (long labels, negatives, real shapes) + * + * Run: + * npx esbuild scripts/theme-plotly.ts --bundle --platform=node --format=esm \ + * --outfile=scripts/.plotly.mjs --external:puppeteer-core --log-level=error \ + * --alias:flint-chart/test-data=./packages/flint-js/src/test-data/index.ts \ + * --alias:flint-chart=./packages/flint-js/src/index.ts + * node scripts/.plotly.mjs --set lab + * node scripts/.plotly.mjs --set r2 bar line # filter by id/chart type + */ + +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { assemblePlotly, plGetTemplateDef } from '../packages/flint-js/src/index'; +import { THEME_PRESETS } from '../packages/flint-js/src/core/theme/presets'; +import { PREVIEW_CASES, type PreviewCase } from '../site/src/shared/preview-cases'; +import { R2_CASES, r2Input, type R2Case } from '../site/src/playground/theme-lab-r2-data'; +import { PlotlyRenderer, type PlotlyPanel } from './plotly-sheet'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const THEME_IDS = Object.keys(THEME_PRESETS); +const COLUMNS = ['flint', ...THEME_IDS]; + +/** The starting set: one chart of each core family, on real data. */ +const LAB_IDS = ['browser-pie', 'causes-death', 'keeling', 'penguins', 'life-expectancy', 'temp-heatmap']; + +/** The second lab set: the families the first set never touched. */ +const LAB2_IDS = [ + 'electricity-stacked', 'medals-grouped', 'population-region', 'penguins-box', + 'population-waterfall', 'education-funnel', 'stock-candle', 'olympic-bump', + 'us-pyramid', 'cities-map', 'renewable-kpi', 'release-gantt', + 'oecd-unemployment-facet', 'faithful-hist', 'nutrition-radar', 'renewables-gauge', +]; + +interface Case { + id: string; + heading: string; + input: () => any; +} + +function realCase(c: PreviewCase): Case { + return { + id: c.id, + heading: `${c.id} · ${c.chartType} · ${c.title}`, + input: () => ({ + data: { values: c.data }, + semantic_types: c.semantic_types, + chart_spec: { + chartType: c.chartType, + title: c.title, + encodings: c.encodings, + baseSize: { width: 320, height: 300 }, + ...(c.chartProperties ? { chartProperties: c.chartProperties } : {}), + }, + }), + }; +} + +function r2Wrapped(c: R2Case): Case { + return { + id: c.id, + heading: `${c.id} · ${c.gen}[${c.index}] · ${c.probe}`, + input: () => r2Input(c), + }; +} + +function corpus(set: string, filters: string[]): Case[] { + let cases: Case[]; + if (set === 'r2') { + cases = R2_CASES.map(r2Wrapped); + } else if (set === 'lab2') { + cases = PREVIEW_CASES.filter((c) => LAB2_IDS.includes(c.id)).map(realCase); + } else if (set === 'real') { + cases = PREVIEW_CASES.map(realCase); + } else { + cases = PREVIEW_CASES.filter((c) => LAB_IDS.includes(c.id)).map(realCase); + } + if (filters.length) { + cases = cases.filter((c) => + filters.some((f) => c.id.toLowerCase().includes(f.toLowerCase()) + || c.heading.toLowerCase().includes(f.toLowerCase())), + ); + } + return cases; +} + +function stripInternal(node: any, depth = 0): void { + if (!node || typeof node !== 'object' || depth > 8) return; + if (Array.isArray(node)) return node.forEach((n) => stripInternal(n, depth + 1)); + for (const key of Object.keys(node)) { + if (/^_[^_]/.test(key)) delete node[key]; + else stripInternal(node[key], depth + 1); + } +} + +function build(c: Case, themeId: string | null): { figure: any | null; report: any[]; error?: string } { + try { + const input = c.input(); + const chartType = input.chart_spec.chartType; + if (!plGetTemplateDef(chartType)) { + return { figure: null, report: [], error: `no Plotly template for \`${chartType}\`` }; + } + const figure = assemblePlotly( + themeId ? { ...input, theme_spec: THEME_PRESETS[themeId].spec } : input, + ); + const report = figure?._theme?.report ?? []; + const clean = { data: figure.data, layout: figure.layout }; + stripInternal(clean); + return { figure: clean, report }; + } catch (err) { + return { figure: null, report: [], error: (err as Error).message }; + } +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const setIdx = argv.indexOf('--set'); + const set = setIdx >= 0 ? argv[setIdx + 1] : 'lab'; + const filters = argv.filter((a, i) => !a.startsWith('--') && i !== setIdx + 1); + + const out = resolve(__dirname, `../audit-out/plotly-${set}`); + const cases = corpus(set, filters); + if (!cases.length) throw new Error(`no cases for set \`${set}\` with filters ${filters.join(',')}`); + if (!filters.length) rmSync(out, { recursive: true, force: true }); + mkdirSync(out, { recursive: true }); + + const renderer = new PlotlyRenderer(); + await renderer.open(); + + const reportLines: string[] = []; + const failures: string[] = []; + let written = 0; + + for (const c of cases) { + const panels: PlotlyPanel[] = []; + for (const col of COLUMNS) { + const built = build(c, col === 'flint' ? null : col); + panels.push({ label: col, figure: built.figure, error: built.error }); + const notes = built.report.map((r: any) => `[${r.stage}] ${r.path} — ${r.message}`); + if (built.error) { + notes.push(`ASSEMBLE FAILED — ${built.error}`); + failures.push(`${c.id}.${col}: ${built.error}`); + } + if (notes.length) { + reportLines.push(`${c.id}.${col}`); + for (const n of notes) reportLines.push(` ${n}`); + } + } + const errs = await renderer.sheet( + { name: c.id, heading: c.heading, panels, cols: 4 }, + out, + ); + for (const e of errs) { + failures.push(`${c.id}: RENDER ${e}`); + reportLines.push(`${c.id} RENDER FAILED — ${e}`); + } + written++; + process.stdout.write(`\r${written}/${cases.length} ${c.id.padEnd(30)}`); + } + + await renderer.close(); + writeFileSync(resolve(out, '_report.txt'), reportLines.join('\n') + '\n'); + console.log(`\nwrote ${written} contact sheets to ${out}`); + if (failures.length) { + console.log(`\n${failures.length} failures:`); + for (const f of failures.slice(0, 40)) console.log(` ${f}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/theme-real.ts b/scripts/theme-real.ts new file mode 100644 index 00000000..cd8101c9 --- /dev/null +++ b/scripts/theme-real.ts @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Real-data theme audit. The R2 corpus (`theme-r2.ts`) drives the houses with + * synthetic gallery generators — clean cardinalities, tidy labels. This harness + * drives them with the *real-world* datasets staged in + * `site/src/playground/new-case-preview-data.ts` (the r1 theme-lab set): real + * category names, real distributions, real negative values and long labels. + * + * It renders each VL-supported case as a contact sheet + * + * flint | nyt | economist | nature + * mckinsey | datawrapper | powerbi | powerbi-light + * + * to `audit-out/real/.png`, plus `_report.txt`. + * + * Run: npx esbuild scripts/theme-real.ts --bundle --platform=node --format=esm \ + * --outfile=scripts/.real.mjs --external:@resvg/resvg-js --log-level=error \ + * --alias:flint-chart/test-data=./packages/flint-js/src/test-data/index.ts \ + * --alias:flint-chart=./packages/flint-js/src/index.ts && node scripts/.real.mjs + * + * node scripts/.real.mjs every VL-supported case + * node scripts/.real.mjs penguins line cases whose id contains "penguins"/"line" + */ + +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { compile } from 'vega-lite'; +import * as vega from 'vega'; +import { Resvg } from '@resvg/resvg-js'; + +import { assembleVegaLite, vlGetTemplateDef } from '../packages/flint-js/src/index'; +import { THEME_PRESETS } from '../packages/flint-js/src/core/theme/presets'; +import { PREVIEW_CASES, type PreviewCase } from '../site/src/shared/preview-cases'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(__dirname, '../audit-out/real'); + +const THEME_IDS = Object.keys(THEME_PRESETS); +const COLUMNS = ['flint', ...THEME_IDS]; + +const GAP = 10; +const LABEL_H = 16; +const GRID_COLS = 4; + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function stripInternal(node: any): void { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) return node.forEach(stripInternal); + for (const key of Object.keys(node)) { + if (/^_[^_]/.test(key)) delete node[key]; + else stripInternal(node[key]); + } +} + +/** Assembly input for a real case — mirrors ThemeLab.tsx `inputFor`. */ +function inputFor(c: PreviewCase): any { + return { + data: { values: c.data }, + semantic_types: c.semantic_types, + chart_spec: { + chartType: c.chartType, + title: c.title, + encodings: c.encodings, + baseSize: { width: 300, height: 300 }, + ...(c.chartProperties ? { chartProperties: c.chartProperties } : {}), + }, + }; +} + +interface Panel { svg: string; width: number; height: number; label: string; background: string } + +async function toSvg(spec: any): Promise<{ svg: string; width: number; height: number }> { + const vgSpec = compile(spec).spec; + const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }); + const svg = await view.toSVG(); + view.finalize(); + const m = /]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"/.exec(svg); + return { svg, width: m ? Number(m[1]) : 400, height: m ? Number(m[2]) : 300 }; +} + +function contactSheet(panels: Panel[], cols: number, heading: string): { svg: string; width: number } { + const colW = Math.ceil(Math.max(...panels.map((p) => p.width))); + const rowH = Math.ceil(Math.max(...panels.map((p) => p.height))) + LABEL_H; + const rows = Math.ceil(panels.length / cols); + const HEAD_H = 20; + const totalW = cols * colW + (cols - 1) * GAP; + const totalH = HEAD_H + rows * rowH + (rows - 1) * GAP; + + let body = ``; + body += `${esc(heading)}`; + panels.forEach((p, i) => { + const x = (i % cols) * (colW + GAP); + const y = HEAD_H + Math.floor(i / cols) * (rowH + GAP); + body += `${esc(p.label)}`; + body += ``; + body += `${p.svg}`; + }); + const svg = `${body}`; + return { svg, width: totalW }; +} + +function backgroundOf(spec: any): string { + return typeof spec?.background === 'string' ? spec.background : '#ffffff'; +} + +interface Built { spec: any; report: any[]; error?: string } + +function build(c: PreviewCase, themeId: string | null): Built { + try { + const input = inputFor(c); + const spec = assembleVegaLite( + themeId ? { ...input, theme_spec: THEME_PRESETS[themeId].spec } : input, + ); + const report = spec._theme?.report ?? []; + stripInternal(spec); + return { spec, report }; + } catch (err) { + return { spec: null, report: [], error: (err as Error).message }; + } +} + +async function panelFor(c: PreviewCase, column: string): Promise<{ panel: Panel; notes: string[] }> { + const themeId = column === 'flint' ? null : column; + const built = build(c, themeId); + const notes: string[] = built.report.map((r: any) => `[${r.stage}] ${r.path} — ${r.message}`); + if (built.error) { + notes.push(`ASSEMBLE FAILED — ${built.error}`); + return { panel: { svg: '', width: 300, height: 300, label: `${column} ✗ assemble`, background: '#ffdddd' }, notes }; + } + try { + const r = await toSvg(built.spec); + return { panel: { ...r, label: column, background: backgroundOf(built.spec) }, notes }; + } catch (err) { + notes.push(`RENDER FAILED — ${(err as Error).message}`); + return { panel: { svg: '', width: 300, height: 300, label: `${column} ✗ render`, background: '#ffdddd' }, notes }; + } +} + +async function main(): Promise { + const filters = process.argv.slice(2).filter((a) => !a.startsWith('--')); + const supported = PREVIEW_CASES.filter((c) => vlGetTemplateDef(c.chartType)); + const skipped = PREVIEW_CASES.filter((c) => !vlGetTemplateDef(c.chartType)); + const cases = filters.length + ? supported.filter((c) => filters.some((f) => c.id.toLowerCase().includes(f.toLowerCase()) || c.chartType.toLowerCase().includes(f.toLowerCase()))) + : supported; + + if (!filters.length) rmSync(OUT, { recursive: true, force: true }); + mkdirSync(OUT, { recursive: true }); + + const reportLines: string[] = []; + const failures: string[] = []; + let written = 0; + + for (const c of cases) { + const panels: Panel[] = []; + for (const col of COLUMNS) { + const { panel, notes } = await panelFor(c, col); + panels.push(panel); + if (notes.length) { + reportLines.push(`${c.id}.${col}`); + for (const n of notes) reportLines.push(` ${n}`); + } + for (const n of notes) if (/FAILED/.test(n)) failures.push(`${c.id}.${col}: ${n}`); + } + const sheet = contactSheet(panels, GRID_COLS, `${c.id} · ${c.chartType} · ${c.title}`); + writeFileSync( + resolve(OUT, `${c.id}.png`), + new Resvg(sheet.svg, { fitTo: { mode: 'width', value: sheet.width * 2 } }).render().asPng(), + ); + written++; + process.stdout.write(`\r${written}/${cases.length} ${c.id.padEnd(28)}`); + } + + if (!filters.length) { + reportLines.push(''); + reportLines.push(`Skipped (no VL template — Plotly-only): ${skipped.map((c) => `${c.id} (${c.chartType})`).join(', ')}`); + } + writeFileSync(resolve(OUT, '_report.txt'), reportLines.join('\n') + '\n'); + console.log(`\nwrote ${written} contact sheets to ${OUT}`); + if (skipped.length && !filters.length) console.log(`skipped ${skipped.length} Plotly-only: ${skipped.map((c) => c.id).join(', ')}`); + if (failures.length) { + console.log(`\n${failures.length} failures:`); + for (const f of failures) console.log(` ${f}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/theme-vs.ts b/scripts/theme-vs.ts new file mode 100644 index 00000000..8e01ac73 --- /dev/null +++ b/scripts/theme-vs.ts @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Side-by-side Vega-Lite / Plotly theme comparison. + * + * The two backend audits (`theme-real.ts`, `theme-plotly.ts`) each render a + * grid of houses for one backend. That answers "is this house consistent?" but + * not "does Plotly read as badly as Vega-Lite here?" — and the second question + * is the one that finds Plotly-only defects, because Vega-Lite has already been + * swept and is the reference. + * + * So this harness pairs them: one row per house, Vega-Lite on the left and + * Plotly on the right, both drawn in the same headless Chrome (Vega-Lite as an + * inline SVG compiled in Node, Plotly live) so the pixels are comparable. + * + * Run: npx esbuild scripts/theme-vs.ts --bundle --platform=node --format=esm \ + * --outfile=scripts/.vs.mjs --external:puppeteer-core --log-level=error \ + * --alias:flint-chart/test-data=./packages/flint-js/src/test-data/index.ts \ + * --alias:flint-chart=./packages/flint-js/src/index.ts && \ + * node scripts/.vs.mjs [house ...] + * + * Output: `audit-out/vs/.png`. + */ + +import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import puppeteer from 'puppeteer-core'; +import { compile } from 'vega-lite'; +import * as vega from 'vega'; + +import { assembleVegaLite, assemblePlotly, vlGetTemplateDef, plGetTemplateDef } from '../packages/flint-js/src/index'; +import { THEME_PRESETS } from '../packages/flint-js/src/core/theme/presets'; +import { PREVIEW_CASES } from '../site/src/shared/preview-cases'; +import { R2_CASES, r2Input } from '../site/src/playground/theme-lab-r2-data'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLOTLY_JS = resolve(__dirname, '../node_modules/plotly.js-dist-min/plotly.min.js'); + +interface ComparableCase { + id: string; + chartType: string; + title?: string; + data: any[]; + semantic_types: Record; + encodings: Record; + chartProperties?: Record; +} + +const CHROME_CANDIDATES = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/google-chrome', +]; + +function chromePath(): string { + const found = CHROME_CANDIDATES.find((p) => existsSync(p)); + if (!found) throw new Error('No Chrome found'); + return found; +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function stripInternal(node: any): void { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) return node.forEach(stripInternal); + for (const key of Object.keys(node)) { + if (/^_[^_]/.test(key)) delete node[key]; + else stripInternal(node[key]); + } +} + +function inputFor(c: ComparableCase): any { + return { + data: { values: c.data }, + semantic_types: c.semantic_types, + chart_spec: { + chartType: c.chartType, + title: c.title, + encodings: c.encodings, + baseSize: { width: 300, height: 300 }, + ...(c.chartProperties ? { chartProperties: c.chartProperties } : {}), + }, + }; +} + +async function vlPanel(c: ComparableCase, house: string | null) { + if (!vlGetTemplateDef(c.chartType)) return { html: '
no VL template
', bg: '#eee' }; + try { + const base = inputFor(c); + const spec: any = assembleVegaLite(house ? { ...base, theme_spec: THEME_PRESETS[house].spec } : base); + const bg = typeof spec.background === 'string' ? spec.background : '#ffffff'; + stripInternal(spec); + delete spec.$schema; + const view = new vega.View(vega.parse(compile(spec).spec), { renderer: 'none' }); + const svg = await view.toSVG(); + view.finalize(); + return { html: svg, bg }; + } catch (err) { + return { html: `
VL failed: ${esc((err as Error).message)}
`, bg: '#ffdddd' }; + } +} + +function plFigure(c: ComparableCase, house: string | null) { + if (!plGetTemplateDef(c.chartType)) return { figure: null, bg: '#eee', err: 'no Plotly template' }; + try { + const base = inputFor(c); + const figure: any = assemblePlotly(house ? { ...base, theme_spec: THEME_PRESETS[house].spec } : base); + return { + figure: { data: figure.data ?? [], layout: figure.layout ?? {} }, + bg: typeof figure.layout?.paper_bgcolor === 'string' ? figure.layout.paper_bgcolor : '#ffffff', + err: '', + }; + } catch (err) { + return { figure: null, bg: '#ffdddd', err: (err as Error).message }; + } +} + +async function main(): Promise { + const rawArgs = process.argv.slice(2); + const r2 = rawArgs[0] === '--set' && rawArgs[1] === 'r2'; + const args = r2 ? rawArgs.slice(2) : rawArgs; + const idFilter = args[0] ?? ''; + const houses = args.length > 1 ? args.slice(1) : ['flint', ...Object.keys(THEME_PRESETS)]; + const source: ComparableCase[] = r2 + ? R2_CASES.map((c) => { + const input = r2Input(c); + return { + id: c.id, + chartType: input.chart_spec.chartType, + title: input.chart_spec.title, + data: input.data.values, + semantic_types: input.semantic_types, + encodings: input.chart_spec.encodings, + chartProperties: input.chart_spec.chartProperties, + }; + }) + : PREVIEW_CASES as ComparableCase[]; + + const cases = source.filter( + (c) => !idFilter || c.id.toLowerCase().includes(idFilter.toLowerCase()) + || c.chartType.toLowerCase().includes(idFilter.toLowerCase()), + ); + if (!cases.length) throw new Error(`no case matches "${idFilter}"`); + + const OUT = resolve(__dirname, r2 ? '../audit-out/vs-r2' : '../audit-out/vs'); + mkdirSync(OUT, { recursive: true }); + const plotlySrc = readFileSync(PLOTLY_JS, 'utf8'); + const browser = await puppeteer.launch({ + executablePath: chromePath(), + headless: true, + args: ['--no-sandbox', '--disable-gpu', '--font-render-hinting=none'], + }); + + for (const c of cases) { + const rows: string[] = []; + const figures: Array<{ id: string; figure: any }> = []; + for (const house of houses) { + const id = house === 'flint' ? null : house; + const vl = await vlPanel(c, id); + const pl = plFigure(c, id); + const key = `pl_${house.replace(/-/g, '_')}`; + if (pl.figure) figures.push({ id: key, figure: pl.figure }); + rows.push(` +
+
${esc(house)}
+
+
vega-lite
${vl.html}
+
plotly
${ + pl.figure ? `
` : `
${esc(pl.err)}
` + }
+
+
`); + } + + const page = await browser.newPage(); + await page.setViewport({ width: 1100, height: 800, deviceScaleFactor: 2 }); + await page.setContent(` + +

${esc(c.id)} · ${esc(c.chartType)} · ${esc(c.title ?? '')}

+ ${rows.join('')} + + `); + + await page.evaluate(async (figs: any[]) => { + for (const f of figs) { + const el = document.getElementById(f.id); + if (el) await (window as any).Plotly.newPlot(el, f.figure.data, f.figure.layout, { staticPlot: true, displayModeBar: false }); + } + }, figures as any); + await new Promise((r) => setTimeout(r, 400)); + + const h = await page.evaluate(() => document.body.scrollHeight); + await page.setViewport({ width: 1100, height: Math.min(h + 20, 30000), deviceScaleFactor: 2 }); + await new Promise((r) => setTimeout(r, 200)); + await page.screenshot({ path: resolve(OUT, `${c.id}.png`) as `${string}.png`, fullPage: true }); + await page.close(); + console.log(`wrote ${c.id}.png`); + } + + await browser.close(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 52bea29a..cdfb39b8 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -3,7 +3,9 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "lib": ["ES2022"], + // These scripts drive a headless browser: the callbacks passed to + // `page.evaluate` run in the page, where `document` and `window` exist. + "lib": ["ES2022", "DOM"], "types": ["node"], "strict": true, "noEmit": true, diff --git a/shared/test-data/bar_chart__14__q_q_20_pts/expected.json b/shared/test-data/bar_chart__14__q_q_20_pts/expected.json index c9a2d1c0..ecc1792a 100644 --- a/shared/test-data/bar_chart__14__q_q_20_pts/expected.json +++ b/shared/test-data/bar_chart__14__q_q_20_pts/expected.json @@ -8,7 +8,7 @@ "field": "X", "type": "quantitative", "scale": { - "zero": true, + "zero": false, "nice": false, "domain": [ 0.5, diff --git a/shared/test-data/bar_chart__15__q_q_30_pts/expected.json b/shared/test-data/bar_chart__15__q_q_30_pts/expected.json index fcbeb8cf..066ae065 100644 --- a/shared/test-data/bar_chart__15__q_q_30_pts/expected.json +++ b/shared/test-data/bar_chart__15__q_q_30_pts/expected.json @@ -8,7 +8,7 @@ "field": "X", "type": "quantitative", "scale": { - "zero": true, + "zero": false, "nice": false, "domain": [ 0.5, diff --git a/shared/test-data/heatmap__01__quant_quant_small_cardinality_nominal/expected.json b/shared/test-data/heatmap__01__quant_quant_small_cardinality_nominal/expected.json index 530d7581..9a96aead 100644 --- a/shared/test-data/heatmap__01__quant_quant_small_cardinality_nominal/expected.json +++ b/shared/test-data/heatmap__01__quant_quant_small_cardinality_nominal/expected.json @@ -9,7 +9,7 @@ "field": "X", "type": "quantitative", "scale": { - "zero": true, + "zero": false, "nice": false, "domain": [ -5, @@ -24,7 +24,7 @@ "field": "Y", "type": "quantitative", "scale": { - "zero": true, + "zero": false, "nice": false, "domain": [ -2.5, diff --git a/shared/test-data/heatmap__02__quant_quant_large_cardinality_resize/expected.json b/shared/test-data/heatmap__02__quant_quant_large_cardinality_resize/expected.json index b2e026ba..2750ebc9 100644 --- a/shared/test-data/heatmap__02__quant_quant_large_cardinality_resize/expected.json +++ b/shared/test-data/heatmap__02__quant_quant_large_cardinality_resize/expected.json @@ -9,7 +9,7 @@ "field": "Hour", "type": "quantitative", "scale": { - "zero": true, + "zero": false, "nice": false, "domain": [ -0.5, @@ -24,7 +24,7 @@ "field": "Day", "type": "quantitative", "scale": { - "zero": true, + "zero": false, "nice": false, "domain": [ -0.5, diff --git a/site/src/components/ThemeChartModal.tsx b/site/src/components/ThemeChartModal.tsx index f5f85716..d370394c 100644 --- a/site/src/components/ThemeChartModal.tsx +++ b/site/src/components/ThemeChartModal.tsx @@ -26,7 +26,11 @@ export function ThemeChartModal({ chart_spec: { chartType: previewCase.chartType, encodings: previewCase.encodings, - baseSize: { width: 720, height: 520 }, + // Laid out small and scaled up, so the type keeps its share of the + // chart. Composed at the modal's own size, the same 10px label is a + // sliver of a 720px frame. The ceiling still lets a dense chart grow. + baseSize: { width: 420, height: 320 }, + canvasSize: { width: 760, height: 560 }, title, ...(previewCase.chartProperties ? { chartProperties: previewCase.chartProperties } : {}), }, @@ -73,9 +77,9 @@ export function ThemeChartModal({
- + {compiled.ok ? ( - + ) : (
{String((compiled.error as Error)?.message ?? compiled.error)}
)} diff --git a/site/src/i18n/messages/en.json b/site/src/i18n/messages/en.json index 37b96fdf..a73b5342 100644 --- a/site/src/i18n/messages/en.json +++ b/site/src/i18n/messages/en.json @@ -35,7 +35,7 @@ "demoAria": "Demo themes", "inspiration": "Need inspiration?", "demos": { - "signal-studio": "Signal Studio", + "google-material": "Google Material", "microsoft-fluent": "Microsoft Fluent", "peoples-daily": "People's Daily" }, @@ -353,6 +353,11 @@ "ctaDocs": "Read the docs", "news": { "title": "Updates", + "release051": { + "date": "August 12, 2026", + "dateTime": "2026-08-12", + "text": "Flint 0.5.1 extends visual themes to the Plotly backend and adds Vega-Lite Calendar Heatmap." + }, "release050": { "date": "August 5, 2026", "dateTime": "2026-08-05", @@ -435,10 +440,10 @@ "subtitle": "Players per month in 2025, one panel per region, by platform" }, "heatmap": { - "label": "Diverging heatmap", - "caption": "Net user gains and losses by region and month, shown with zero-centered color.", - "title": "Which titles gained players, and when", - "subtitle": "Net new players per game per month in 2025; positive values are gains, negative values are losses" + "label": "Temperature heatmap", + "caption": "Monthly climate normals across four cities, shown as a compact table of values.", + "title": "How seasons differ across four cities", + "subtitle": "Average monthly temperature in °C; climate normals" }, "waterfall": { "label": "Waterfall", diff --git a/site/src/i18n/messages/zh-CN.json b/site/src/i18n/messages/zh-CN.json index bee916a8..e652bd7d 100644 --- a/site/src/i18n/messages/zh-CN.json +++ b/site/src/i18n/messages/zh-CN.json @@ -35,7 +35,7 @@ "demoAria": "示例主题", "inspiration": "需要灵感?", "demos": { - "signal-studio": "信号工作室", + "google-material": "Google Material", "microsoft-fluent": "Microsoft Fluent", "peoples-daily": "人民日报" }, @@ -353,6 +353,11 @@ "ctaDocs": "阅读文档", "news": { "title": "更新", + "release051": { + "date": "2026 年 8 月 12 日", + "dateTime": "2026-08-12", + "text": "Flint 0.5.1 将视觉主题扩展到 Plotly 后端,并新增日历热力图图表类型。" + }, "release050": { "date": "2026 年 8 月 5 日", "dateTime": "2026-08-05", @@ -435,10 +440,10 @@ "subtitle": "2025 年逐月活跃玩家数,每个地区一幅小图,按平台着色" }, "heatmap": { - "label": "发散热力图", - "caption": "按地区与月份展示用户净增减,颜色以零为中心。", - "title": "哪些游戏在何时获得了玩家", - "subtitle": "2025 年各游戏逐月净增用户;正值表示增长,负值表示流失" + "label": "温度热力图", + "caption": "以紧凑数值表格展示四座城市的逐月气候常值。", + "title": "四座城市的季节差异", + "subtitle": "月平均气温(°C);气候常值" }, "waterfall": { "label": "瀑布图", diff --git a/site/src/main.tsx b/site/src/main.tsx index 4b10cefc..fb8c034f 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -21,6 +21,7 @@ import { DemoWall } from './playground/DemoWall'; import { ThemeLab } from './playground/ThemeLab'; import { ThemeLabR2 } from './playground/ThemeLabR2'; import { ThemeLabReal } from './playground/ThemeLabReal'; +import { BandStretchingLab } from './playground/BandStretchingLab'; import { StyleReferences } from './playground/StyleReferences'; import { FullTestCases } from './playground/FullTestCases'; import { LocaleProvider, useLocale } from './i18n/LocaleContext'; @@ -68,6 +69,7 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> } /> {/* The Swiss and cartoon labs were the same page twice; keep the links they were reached by working. */} diff --git a/site/src/playground/BandStretchingLab.tsx b/site/src/playground/BandStretchingLab.tsx new file mode 100644 index 00000000..1dd91a37 --- /dev/null +++ b/site/src/playground/BandStretchingLab.tsx @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useMemo, useState, type CSSProperties } from 'react'; +import { Check, Copy, RotateCcw } from 'lucide-react'; +import { THEME_PRESETS, assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; +import { VegaLiteView } from '../components/VegaLiteView'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { siteTheme } from '../shared/theme'; +import './band-stretching-lab.css'; + +type CaseId = 'bar' | 'waterfall'; +type ThemeId = 'flint' | string; + +interface LabSettings { + caseId: CaseId; + count: number; + baseSize: number; + capacityWidth: number; +} + +interface CellResult { + spec?: any; + error?: string; + step: number; + solid: number; + gap: number; + plotWidth: number; + plotHeight: number; +} + +const COLUMNS: ThemeId[] = ['flint', ...Object.keys(THEME_PRESETS)]; +const DEFAULT_SETTINGS: LabSettings = { + caseId: 'waterfall', + count: 12, + baseSize: 440, + capacityWidth: 720, +}; +const TILE_WIDTH = 340; +const TILE_HEIGHT = 300; + +function preset(themeId: ThemeId): any | undefined { + return themeId === 'flint' ? undefined : (THEME_PRESETS as any)[themeId]; +} + +function initialFits(): Record { + return Object.fromEntries(COLUMNS.map((themeId) => [ + themeId, + preset(themeId)?.spec?.layout?.bandStepFit ?? 0, + ])); +} + +function rows(count: number): Array> { + return Array.from({ length: count }, (_, index) => ({ + Category: `P${String(index + 1).padStart(2, '0')}`, + Value: index === 0 + ? 40 + : Math.round((14 + ((index * 17) % 31)) * (index % 4 === 0 ? -1 : 1)), + })); +} + +function inputFor(settings: LabSettings, themeId: ThemeId, fit: number): ChartAssemblyInput { + const chartType = settings.caseId === 'waterfall' ? 'Waterfall Chart' : 'Bar Chart'; + const input: ChartAssemblyInput = { + data: { values: rows(settings.count) }, + semantic_types: { Category: 'Category', Value: settings.caseId === 'waterfall' ? 'Profit' : 'Quantity' }, + chart_spec: { + chartType, + encodings: { x: 'Category', y: 'Value' }, + title: settings.caseId === 'waterfall' ? 'Cumulative change' : 'Value by period', + subtitle: `${settings.count} categories · band X, continuous Y`, + baseSize: { width: settings.baseSize, height: settings.baseSize }, + canvasSize: { width: settings.capacityWidth, height: settings.baseSize }, + }, + options: { bandStepFit: fit }, + }; + const theme = preset(themeId); + return theme ? { ...input, theme_spec: theme.spec } : input; +} + +function findPaddingInner(node: any): number | undefined { + if (!node || typeof node !== 'object') return undefined; + if (node.encoding?.x?.scale && typeof node.encoding.x.scale.paddingInner === 'number') { + return node.encoding.x.scale.paddingInner; + } + for (const value of Object.values(node)) { + const found = findPaddingInner(value); + if (found != null) return found; + } + return undefined; +} + +function stripInternal(node: any): void { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach(stripInternal); + return; + } + for (const key of Object.keys(node)) { + if (/^_[^_]/.test(key)) delete node[key]; + else stripInternal(node[key]); + } +} + +function compile(settings: LabSettings, themeId: ThemeId, fit: number): CellResult { + try { + const spec = assembleVegaLite(inputFor(settings, themeId, fit) as any) as any; + const step = Number(spec.width?.step ?? 0); + const padding = findPaddingInner(spec) ?? 0.2; + const gap = step * padding; + const solid = step - gap; + const result = { + spec, + step, + solid, + gap, + plotWidth: step * settings.count, + plotHeight: Number(spec._height ?? settings.baseSize), + }; + stripInternal(spec); + delete spec.$schema; + return result; + } catch (error) { + return { + error: String((error as Error)?.message ?? error), + step: 0, + solid: 0, + gap: 0, + plotWidth: 0, + plotHeight: 0, + }; + } +} + +function Slider({ + label, + value, + min, + max, + step = 1, + suffix = '', + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + step?: number; + suffix?: string; + onChange: (value: number) => void; +}) { + const pct = ((value - min) / (max - min)) * 100; + return ( + + ); +} + +function ThemeCell({ + themeId, + settings, + fit, + onFit, +}: { + themeId: ThemeId; + settings: LabSettings; + fit: number; + onFit: (fit: number) => void; +}) { + const theme = preset(themeId); + const built = useMemo(() => compile(settings, themeId, fit), [settings, themeId, fit]); + const baseStep = theme?.spec?.layout?.bandStep ?? 20; + const occupancy = theme?.spec?.marks?.bandFraction; + const ratio = built.plotHeight > 0 ? built.plotWidth / built.plotHeight : 0; + + return ( +
+
+ {theme?.label ?? 'Flint default'} + + base {baseStep}px{occupancy != null ? ` · fill ${Math.round(occupancy * 100)}%` : ''} + +
+ +
+ {built.error ? ( +
{built.error}
+ ) : ( + + + + )} +
+ + + +
+ {[ + ['pitch', built.step], + ['solid', built.solid], + ['gap', built.gap], + ['plot W', built.plotWidth], + ['AR', ratio], + ].map(([label, value]) => ( +
+
{label}
+
+ {typeof value === 'number' ? (label === 'AR' ? value.toFixed(2) : `${value.toFixed(1)}px`) : value} +
+
+ ))} +
+
+ ); +} + +export function BandStretchingLab() { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [fits, setFits] = useState>(initialFits); + const [copied, setCopied] = useState(false); + + const patch = (next: Partial) => setSettings((current) => ({ ...current, ...next })); + const reset = () => { + setSettings(DEFAULT_SETTINGS); + setFits(initialFits()); + }; + const copyFits = async () => { + const values = Object.fromEntries( + Object.entries(fits) + .filter(([themeId]) => themeId !== 'flint') + .map(([themeId, fit]) => [themeId, { bandStepFit: fit }]), + ); + await navigator.clipboard.writeText(JSON.stringify(values, null, 2)); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }; + + return ( +
+
+

Band stretching lab

+

+ Calibrate each house independently on the same Vega-Lite band-X / continuous-Y geometry. bandStepFit only broadens sparse bands toward an explicit canvas capacity, up to a fixed readability ceiling; dense pressure and two-banded grids are outside this surface. +

+
+ +
+
+
+ {([['bar', 'Bar'], ['waterfall', 'Waterfall']] as const).map(([id, label]) => ( + + ))} +
+ +
+ patch({ count })} /> + patch({ baseSize, capacityWidth: Math.max(baseSize, settings.capacityWidth) })} /> + patch({ capacityWidth })} /> +
+ +
+ + +
+
+
+ +
+ {COLUMNS.map((themeId) => ( + setFits((current) => ({ ...current, [themeId]: fit }))} + /> + ))} +
+
+ ); +} diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index 5fdefef0..3c0cc16d 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -16,6 +16,7 @@ const pages: NavEntry[] = [ { to: 'theme-labs', label: 'Theme lab' }, { to: 'theme-lab-r2', label: 'Theme lab R2' }, { to: 'theme-lab-real', label: 'Theme lab real' }, + { to: 'band-stretching', label: 'Band stretching' }, { to: 'style-references', label: 'Style references' }, ], }, diff --git a/site/src/playground/ThemeLabR2.tsx b/site/src/playground/ThemeLabR2.tsx index 455b7aaf..c2c366ea 100644 --- a/site/src/playground/ThemeLabR2.tsx +++ b/site/src/playground/ThemeLabR2.tsx @@ -22,7 +22,7 @@ import { type R2Case, type R2Family, } from './theme-lab-r2-data'; -import { R2Cell, R2_COLUMNS } from './ThemeLabR2Cell'; +import { R2Cell, R2_COLUMNS, type LabBackend } from './ThemeLabR2Cell'; function byFamily(family: R2Family): R2Case[] { return R2_CASES.filter((c) => c.family === family); @@ -47,7 +47,46 @@ function Pill({ children }: { children: ReactNode }) { ); } -function Row({ c }: { c: R2Case }) { +/** + * The same ThemeSpec is realized by two backends. Switching between them is + * the whole point of the lab: a house that only looks right in one of them is + * a gap in the shared decisions, not a rendering detail. + */ +export function BackendSwitch({ + value, + onChange, +}: { + value: LabBackend; + onChange: (b: LabBackend) => void; +}) { + return ( +
+ backend + {(['vegalite', 'plotly'] as const).map((b) => { + const active = b === value; + return ( + + ); + })} +
+ ); +} + +function Row({ c, backend }: { c: R2Case; backend: LabBackend }) { return (
@@ -71,7 +110,7 @@ function Row({ c }: { c: R2Case }) { }} > {R2_COLUMNS.map((col) => ( - + ))}
@@ -80,6 +119,7 @@ function Row({ c }: { c: R2Case }) { export function ThemeLabR2() { const [family, setFamily] = useState(R2_FAMILY_ORDER[0]); + const [backend, setBackend] = useState('vegalite'); const cases = byFamily(family); return ( @@ -88,6 +128,7 @@ export function ThemeLabR2() {

Theme lab · round 2 (coverage)

+